Skip to content

Commit 669f152

Browse files
Merge pull request #6 from digitaldrreamer/claude/fix-cursor-oom
fix(cursor): stop OOM on large databases (v0.1.1)
2 parents 0ff0942 + 593ea5a commit 669f152

4 files changed

Lines changed: 67 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
55
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.1.1] — 2026-07-02
8+
9+
### Fixed
10+
- **Cursor adapter out-of-memory crash.** Discovery selected `key, value` across
11+
Cursor's entire `ItemTable` / `cursorDiskKV`, loading every blob (embeddings,
12+
file caches — often gigabytes) into memory at once and crashing with "heap out
13+
of memory" on large databases. Discovery now selects only keys; each row's
14+
value is fetched lazily when it's actually scanned.
15+
716
## [0.1.0] — 2026-07-02
817

918
First public release.
@@ -36,4 +45,5 @@ First public release.
3645
- Extended-thinking blocks are passed through the proxy unredacted to preserve
3746
their signatures — see the README Limitations section.
3847

48+
[0.1.1]: https://github.com/digitaldrreamer/broomsticks/releases/tag/v0.1.1
3949
[0.1.0]: https://github.com/digitaldrreamer/broomsticks/releases/tag/v0.1.0

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "broomsticks",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Sweep leaked secrets out of your AI coding-assistant chat transcripts (Claude Code, Codex, Cursor). Dry-run by default, backups before every write, zero runtime dependencies.",
55
"type": "module",
66
"bin": {

src/sources/cursor.mjs

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,28 @@ function* walkVscdb(dir) {
6060
}
6161

6262
/**
63-
* Open a SQLite database read-only and return all AI-related rows from the
64-
* known tables. Returns [] if the DB is unreadable or has no matching tables.
63+
* Open a SQLite database read-only and return the AI-related (table, key) pairs
64+
* from the known tables. Returns [] if the DB is unreadable or has no matching
65+
* tables.
66+
*
67+
* Only the `key` column is selected here — never `value`. Cursor's tables can
68+
* hold gigabytes of blobs (embeddings, file caches), so `SELECT key, value …`
69+
* across a whole table loads every blob into memory at once and OOMs the
70+
* process. Each row's value is fetched lazily, one at a time, in the Target's
71+
* `read()`, so discovery only needs the (small) keys.
6572
*
6673
* @param {string} dbPath
67-
* @returns {Array<{table:string, key:string, value:string}>}
74+
* @returns {Array<{table:string, key:string}>}
6875
*/
69-
function readAiRows(dbPath) {
76+
function readAiKeys(dbPath) {
7077
let db
7178
try {
7279
db = new DatabaseSync(dbPath, { open: true, readOnly: true })
7380
} catch {
7481
return []
7582
}
7683

77-
const rows = []
84+
const keys = []
7885

7986
// Check which tables actually exist before querying
8087
let existingTables
@@ -92,14 +99,11 @@ function readAiRows(dbPath) {
9299
for (const table of TABLES) {
93100
if (!existingTables.has(table)) continue
94101
try {
95-
const all = db.prepare(`SELECT key, value FROM [${table}]`).all()
96-
for (const row of all) {
102+
// key-only projection, streamed via iterate() so we never materialise the
103+
// full key array either (values are never loaded in bulk — see above)
104+
for (const row of db.prepare(`SELECT key FROM [${table}]`).iterate()) {
97105
if (typeof row.key === 'string' && AI_KEY_RE.test(row.key)) {
98-
// value may be stored as Buffer (BLOB) or string — normalise to string
99-
const value = Buffer.isBuffer(row.value)
100-
? row.value.toString('utf8')
101-
: String(row.value ?? '')
102-
rows.push({ table, key: row.key, value })
106+
keys.push({ table, key: row.key })
103107
}
104108
}
105109
} catch {
@@ -108,7 +112,7 @@ function readAiRows(dbPath) {
108112
}
109113

110114
db.close()
111-
return rows
115+
return keys
112116
}
113117

114118
/**
@@ -123,9 +127,7 @@ export function discoverTargets() {
123127
const targets = []
124128

125129
for (const dbPath of walkVscdb(userRoot)) {
126-
const rows = readAiRows(dbPath)
127-
128-
for (const { table, key, value } of rows) {
130+
for (const { table, key } of readAiKeys(dbPath)) {
129131
// Capture loop vars in closure-safe consts
130132
const db = dbPath
131133
const tbl = table

test/cursor.test.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,41 @@ test('cursor target read → redact → write persists the redaction', (t) => {
6262
rmSync(home, { recursive: true, force: true })
6363
}
6464
})
65+
66+
// Regression guard for the OOM: discovery must not bulk-load row values. A big
67+
// non-AI blob (Cursor stores embeddings/caches this way) sitting next to a small
68+
// AI row must be ignored by discovery — previously `SELECT key, value FROM …`
69+
// pulled every blob into memory at once and crashed on large databases.
70+
test('cursor discovery ignores large non-AI blobs and reads values lazily', (t) => {
71+
const home = mkdtempSync(join(tmpdir(), 'broom-cursor-oom-'))
72+
const prevHome = process.env.HOME
73+
process.env.HOME = home
74+
75+
try {
76+
const root = cursorUserRoot()
77+
if (!root.startsWith(home)) return t.skip('os.homedir() does not honor $HOME here')
78+
79+
const storage = join(root, 'globalStorage')
80+
mkdirSync(storage, { recursive: true })
81+
const dbPath = join(storage, 'state.vscdb')
82+
83+
const bigBlob = 'x'.repeat(8 * 1024 * 1024) // 8 MB non-AI value
84+
85+
const db = new DatabaseSync(dbPath)
86+
db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)')
87+
const ins = db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)')
88+
ins.run('workbench.embeddings.cache', bigBlob) // non-AI: must be skipped
89+
ins.run('history.entries', bigBlob) // non-AI: must be skipped
90+
ins.run('composer.chat', `msg ${SECRET} msg`) // AI: must be discovered
91+
db.close()
92+
93+
const targets = discoverTargets()
94+
assert.equal(targets.length, 1, 'only the AI row is discovered, not the blobs')
95+
assert.equal(targets[0].label.endsWith(':composer.chat'), true)
96+
assert.ok(targets[0].read().includes(SECRET), 'value is fetched lazily on read')
97+
} finally {
98+
if (prevHome === undefined) delete process.env.HOME
99+
else process.env.HOME = prevHome
100+
rmSync(home, { recursive: true, force: true })
101+
}
102+
})

0 commit comments

Comments
 (0)