residoo 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +225 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +82 -16
- package/src/integrity.js +669 -0
- package/src/patterns.js +78 -5
- package/src/report.js +74 -7
- package/src/sources/agent-configs.js +308 -0
- package/src/sources/aider.js +361 -0
- package/src/sources/amazon-q.js +199 -0
- package/src/sources/antigravity-cli.js +155 -0
- package/src/sources/cline.js +208 -0
- package/src/sources/codebuff.js +295 -0
- package/src/sources/codex-cli.js +258 -0
- package/src/sources/cody.js +325 -0
- package/src/sources/continue.js +408 -0
- package/src/sources/copilot-chat.js +272 -0
- package/src/sources/copilot-cli.js +300 -0
- package/src/sources/crush.js +364 -0
- package/src/sources/cursor.js +374 -0
- package/src/sources/devin-cli.js +241 -0
- package/src/sources/factory-droid.js +153 -0
- package/src/sources/fx.js +136 -0
- package/src/sources/gemini-cli.js +242 -0
- package/src/sources/goose.js +366 -0
- package/src/sources/grok-cli.js +267 -0
- package/src/sources/hermes.js +282 -0
- package/src/sources/index.js +172 -8
- package/src/sources/jetbrains-ai-assistant.js +343 -0
- package/src/sources/jetbrains-junie.js +292 -0
- package/src/sources/kilo-code.js +430 -0
- package/src/sources/kimi-code.js +147 -0
- package/src/sources/kiro-cli.js +393 -0
- package/src/sources/kiro-ide.js +230 -0
- package/src/sources/llm.js +328 -0
- package/src/sources/mentat.js +143 -0
- package/src/sources/open-interpreter.js +224 -0
- package/src/sources/openclaw.js +218 -0
- package/src/sources/opencode.js +379 -0
- package/src/sources/openhands.js +181 -0
- package/src/sources/pearai.js +151 -0
- package/src/sources/pi-agent.js +130 -0
- package/src/sources/qodo-gen.js +189 -0
- package/src/sources/qwen-code.js +244 -0
- package/src/sources/roo-code.js +239 -0
- package/src/sources/trae.js +294 -0
- package/src/sources/void.js +273 -0
- package/src/sources/warp.js +395 -0
- package/src/sources/windsurf.js +256 -0
- package/src/sources/zed.js +374 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cursor's local chat/composer history.
|
|
9
|
+
*
|
|
10
|
+
* VERIFICATION STATUS (read this before trusting anything below): the paths
|
|
11
|
+
* and schema here are corroborated by multiple independent community
|
|
12
|
+
* write-ups — including one built from a real `sqlite3 .tables` / row-count
|
|
13
|
+
* inspection of an actual, live Cursor install on Linux, and a real, working
|
|
14
|
+
* desktop tool (not just a blog post) that reads/writes these same
|
|
15
|
+
* tables/keys — cross-checked against several more descriptions that agree
|
|
16
|
+
* with each other on table names, key patterns, and per-OS paths. What this
|
|
17
|
+
* source has NOT been checked against is a real Cursor install on the
|
|
18
|
+
* machine it was built on — Cursor isn't installed there. See CONTRIBUTING.md
|
|
19
|
+
* and this source's PR description for exactly what was and wasn't verified.
|
|
20
|
+
* If you have Cursor installed, the most useful thing you can do is run
|
|
21
|
+
* `residoo scan` and confirm `sourcesScanned`/`filesScanned` look right for
|
|
22
|
+
* what you know is actually on disk, then report back either way.
|
|
23
|
+
*
|
|
24
|
+
* Cursor is a VS Code fork and reuses VS Code's per-profile SQLite storage
|
|
25
|
+
* file for editor/workbench state: `state.vscdb`, containing a table called
|
|
26
|
+
* `ItemTable`. Cursor adds its own table, `cursorDiskKV`, in the same file
|
|
27
|
+
* for chat/composer data. Both tables share the same two-column shape:
|
|
28
|
+
* `key TEXT UNIQUE, value BLOB` — one row per key, value a UTF-8 JSON blob
|
|
29
|
+
* (confirmed directly against this project's own Node/node:sqlite: see
|
|
30
|
+
* valueToText() below for the two storage-class shapes actually observed).
|
|
31
|
+
*
|
|
32
|
+
* Two copies of this file exist per install, and which one holds the actual
|
|
33
|
+
* message text has reportedly moved across Cursor versions (per the sources
|
|
34
|
+
* above — composer/chat data has lived in globalStorage in some versions,
|
|
35
|
+
* with workspaceStorage holding only UI/pointer state, and the reverse has
|
|
36
|
+
* also been reported for older versions). Rather than guess which is current
|
|
37
|
+
* for whatever version is installed, this source reads BOTH, everywhere
|
|
38
|
+
* found:
|
|
39
|
+
* - globalStorage/state.vscdb — one per Cursor profile.
|
|
40
|
+
* - workspaceStorage/<hash>/state.vscdb — one per opened project/folder.
|
|
41
|
+
*
|
|
42
|
+
* Key-name filtering (only reading rows named `bubbleId:...`,
|
|
43
|
+
* `composerData:...`, etc.) was deliberately NOT done, for the same reason:
|
|
44
|
+
* the exact universe of "which keys hold real content" has already changed
|
|
45
|
+
* across Cursor versions in the research for this source (composer.composerData
|
|
46
|
+
* vs composer.composerHeaders vs workbench.panel.aichat.view.aichat.chatdata
|
|
47
|
+
* vs agentKv:blob:* were all reported as real, in different versions or
|
|
48
|
+
* subsystems). scan.js already matches raw text regardless of the structure
|
|
49
|
+
* it came from (see its own docstring) — this source reuses that same
|
|
50
|
+
* tolerance by turning every row's value into one scanned line, rather than
|
|
51
|
+
* hard-coding a key allowlist likely to go stale the same way the sources
|
|
52
|
+
* above show it already has.
|
|
53
|
+
*/
|
|
54
|
+
function cursorUserDir() {
|
|
55
|
+
const home = os.homedir();
|
|
56
|
+
if (process.platform === "darwin") {
|
|
57
|
+
return path.join(home, "Library", "Application Support", "Cursor", "User");
|
|
58
|
+
}
|
|
59
|
+
if (process.platform === "win32") {
|
|
60
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
61
|
+
return path.join(appData, "Cursor", "User");
|
|
62
|
+
}
|
|
63
|
+
// Linux and other XDG-following unix platforms.
|
|
64
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
65
|
+
return path.join(configHome, "Cursor", "User");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const USER_DIR = cursorUserDir();
|
|
69
|
+
const GLOBAL_STORAGE_DB = path.join(USER_DIR, "globalStorage", "state.vscdb");
|
|
70
|
+
const WORKSPACE_STORAGE_DIR = path.join(USER_DIR, "workspaceStorage");
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* node:sqlite is a Node CORE module (built into the `node` binary itself),
|
|
74
|
+
* not a package resolved from node_modules — using it adds no entry to
|
|
75
|
+
* package.json, no lockfile line, nothing `npm audit`/a supply-chain scan
|
|
76
|
+
* would ever see. That is the actual distinction CONTRIBUTING.md's rule 1
|
|
77
|
+
* ("zero runtime dependencies") cares about; Node itself still labels the
|
|
78
|
+
* module "experimental" (it prints an ExperimentalWarning on first use,
|
|
79
|
+
* visible if you run residoo with `--trace-warnings`), which is a stability
|
|
80
|
+
* promise, not a supply-chain one, and irrelevant to that rule.
|
|
81
|
+
*
|
|
82
|
+
* It has been available, unflagged, since Node 22.5.
|
|
83
|
+
*
|
|
84
|
+
* Loaded LAZILY (see getDatabaseSync() below), not at module require() time.
|
|
85
|
+
* require("node:sqlite") makes Node print an ExperimentalWarning to stderr
|
|
86
|
+
* on its first successful load per process (verified directly: calling
|
|
87
|
+
* require() again afterwards, even many times, does not repeat it — the
|
|
88
|
+
* module cache absorbs the rest). index.js requires every registered source
|
|
89
|
+
* unconditionally so ALL_SOURCES can exist, and cli.js calls available() on
|
|
90
|
+
* every one of them on every single `residoo scan` — an eager top-level
|
|
91
|
+
* require here would print that warning on every invocation, forever, for
|
|
92
|
+
* every user on Node 22.5+, even the large majority who have never touched
|
|
93
|
+
* Cursor. Deferring the require until there is already a concrete reason to
|
|
94
|
+
* make it (Cursor's own directory actually exists on this machine) confines
|
|
95
|
+
* that warning to the case where it's actually informative.
|
|
96
|
+
*/
|
|
97
|
+
const NODE_SQLITE_REQUIREMENT = "needs Node.js 22.5+ (node:sqlite not present in this runtime)";
|
|
98
|
+
let sqliteRequireAttempted = false;
|
|
99
|
+
let DatabaseSync = null;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve node:sqlite's DatabaseSync, requiring it at most once per process
|
|
103
|
+
* (subsequent calls reuse the cached result, success or failure alike).
|
|
104
|
+
*
|
|
105
|
+
* Deliberately does NOT gate on userDirExists() itself — readLines(file), by
|
|
106
|
+
* contract, must still work for whatever file path is handed to it (this
|
|
107
|
+
* mirrors claude-code.js's readLines(), which likewise never checks its own
|
|
108
|
+
* available() before trying to read a file). It is available()'s and
|
|
109
|
+
* unavailableReason()'s job to skip calling this at all when there's
|
|
110
|
+
* plainly nothing to read yet — see their bodies below.
|
|
111
|
+
*/
|
|
112
|
+
function getDatabaseSync() {
|
|
113
|
+
if (!sqliteRequireAttempted) {
|
|
114
|
+
sqliteRequireAttempted = true;
|
|
115
|
+
try { ({ DatabaseSync } = require("node:sqlite")); }
|
|
116
|
+
catch { DatabaseSync = null; }
|
|
117
|
+
}
|
|
118
|
+
return DatabaseSync;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function id() { return "cursor"; }
|
|
122
|
+
function label() { return "Cursor"; }
|
|
123
|
+
|
|
124
|
+
function userDirExists() {
|
|
125
|
+
try { return fs.statSync(USER_DIR).isDirectory(); } catch { return false; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function available() {
|
|
129
|
+
// Cheap fs check first, on purpose: the common case is Cursor simply
|
|
130
|
+
// isn't installed, and answering "not available" for that reason alone
|
|
131
|
+
// must not cost requiring node:sqlite — see getDatabaseSync()'s docstring
|
|
132
|
+
// for exactly why that matters here. Short-circuit evaluation means
|
|
133
|
+
// getDatabaseSync() (and its possible warning) is never reached when
|
|
134
|
+
// userDirExists() is already false.
|
|
135
|
+
return userDirExists() && Boolean(getDatabaseSync());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Optional, additive beyond the { id, label, available, files, readLines }
|
|
140
|
+
* contract every source implements — scan.js and index.js never call this;
|
|
141
|
+
* only cli.js's own "why is a source missing" messaging does, and only when
|
|
142
|
+
* present (`typeof source.unavailableReason === "function"`). Every other
|
|
143
|
+
* source can safely ignore this export entirely.
|
|
144
|
+
*
|
|
145
|
+
* Returns a human-readable reason string in the one case worth calling out
|
|
146
|
+
* specifically — Cursor IS installed (its User profile directory is really
|
|
147
|
+
* there) but this Node runtime is too old for node:sqlite, so the source
|
|
148
|
+
* silently vanishing from "Sources checked" would read as "Cursor isn't
|
|
149
|
+
* installed," which is false and unhelpful. Returns null otherwise,
|
|
150
|
+
* including the ordinary "Cursor just isn't here" case, where saying nothing
|
|
151
|
+
* more is the correct, unremarkable answer.
|
|
152
|
+
*/
|
|
153
|
+
function unavailableReason() {
|
|
154
|
+
// Same short-circuit-first shape as available(), and for the same reason:
|
|
155
|
+
// don't pay for (or warn about) a node:sqlite require when Cursor isn't
|
|
156
|
+
// even on this machine, where the answer is "nothing to say" regardless.
|
|
157
|
+
if (!userDirExists()) return null;
|
|
158
|
+
if (getDatabaseSync()) return null; // sqlite is fine; available() already covers this source correctly
|
|
159
|
+
return `Cursor detected but not scanned — ${NODE_SQLITE_REQUIREMENT}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Same defensive symlink-following pattern as claude-code.js's
|
|
164
|
+
* isDirFollowingSymlink — see that file's docstring for the full reasoning
|
|
165
|
+
* (a relocated/symlinked directory should still be scanned, not silently
|
|
166
|
+
* excluded because Dirent reflects lstat semantics for symlinks). Duplicated
|
|
167
|
+
* here rather than imported: each source in this project is meant to be a
|
|
168
|
+
* small, self-contained file a reviewer can audit on its own — see
|
|
169
|
+
* CONTRIBUTING.md.
|
|
170
|
+
*/
|
|
171
|
+
function isDirFollowingSymlink(fullPath, dirent) {
|
|
172
|
+
if (dirent.isDirectory()) return true;
|
|
173
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
174
|
+
try { return fs.statSync(fullPath).isDirectory(); } catch { return false; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve one candidate `state.vscdb` path into zero or one files() entries.
|
|
179
|
+
*
|
|
180
|
+
* Uses lstat directly rather than readdirSync+Dirent, because these paths
|
|
181
|
+
* are constructed (globalStorage/state.vscdb is a fixed, known filename; a
|
|
182
|
+
* workspaceStorage entry's state.vscdb is joined onto an already-resolved
|
|
183
|
+
* directory) rather than discovered by listing a directory — there is no
|
|
184
|
+
* Dirent available to reuse the isDirFollowingSymlink-style check against.
|
|
185
|
+
*
|
|
186
|
+
* A path that simply does not exist yields nothing: for globalStorage that
|
|
187
|
+
* would be unusual, but for a workspaceStorage/<hash> directory that never
|
|
188
|
+
* happened to write a state.vscdb, or wrote one and later had it removed, it
|
|
189
|
+
* is normal and NOT a "broken" entry — broken is reserved for a path that
|
|
190
|
+
* looked like it should resolve to a real file and didn't (a dangling
|
|
191
|
+
* symlink, chiefly), exactly the same convention claude-code.js's files()
|
|
192
|
+
* uses.
|
|
193
|
+
*/
|
|
194
|
+
function* statIfPresent(dbPath) {
|
|
195
|
+
let lst;
|
|
196
|
+
try { lst = fs.lstatSync(dbPath); }
|
|
197
|
+
catch { return; }
|
|
198
|
+
|
|
199
|
+
if (lst.isSymbolicLink()) {
|
|
200
|
+
try {
|
|
201
|
+
const st = fs.statSync(dbPath); // follow the link
|
|
202
|
+
if (!st.isFile()) { yield { file: dbPath, broken: true }; return; }
|
|
203
|
+
yield { file: dbPath, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
204
|
+
} catch {
|
|
205
|
+
yield { file: dbPath, broken: true }; // dangling symlink
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!lst.isFile()) return; // e.g. something unexpected sits at this path — out of scope, not broken
|
|
211
|
+
yield { file: dbPath, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every state.vscdb found —
|
|
216
|
+
* one for globalStorage, one per workspaceStorage/<hash> directory.
|
|
217
|
+
*
|
|
218
|
+
* Purely a filesystem walk + stat, same division of labour as
|
|
219
|
+
* claude-code.js's files(): this function never opens the database, so it
|
|
220
|
+
* works (and can be exercised in tests) even in a Node runtime where
|
|
221
|
+
* node:sqlite isn't available — only readLines() actually needs it.
|
|
222
|
+
*/
|
|
223
|
+
function* files() {
|
|
224
|
+
yield* statIfPresent(GLOBAL_STORAGE_DB);
|
|
225
|
+
|
|
226
|
+
let workspaceDirs;
|
|
227
|
+
try { workspaceDirs = fs.readdirSync(WORKSPACE_STORAGE_DIR, { withFileTypes: true }); }
|
|
228
|
+
catch { return; } // no workspaceStorage directory at all — nothing more to walk
|
|
229
|
+
|
|
230
|
+
for (const ws of workspaceDirs) {
|
|
231
|
+
const wsDir = path.join(WORKSPACE_STORAGE_DIR, ws.name);
|
|
232
|
+
if (!isDirFollowingSymlink(wsDir, ws)) {
|
|
233
|
+
if (ws.isSymbolicLink()) yield { file: wsDir, broken: true };
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
yield* statIfPresent(path.join(wsDir, "state.vscdb"));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A state.vscdb this large has not been observed anywhere in this source's
|
|
241
|
+
// research (a real, live install inspected during that research had ~50,000
|
|
242
|
+
// rows total across both tables, nowhere near this many bytes) — unlike
|
|
243
|
+
// claude-code.js's MAX_BYTES, this is not backed by a real large file this
|
|
244
|
+
// tool was tested against, only a generous backstop against a corrupted or
|
|
245
|
+
// pathological file.
|
|
246
|
+
const MAX_DB_BYTES = 512 * 1024 * 1024;
|
|
247
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
248
|
+
const BUSY_TIMEOUT_MS = 5_000; // bound how long a read waits on a lock Cursor itself may be holding
|
|
249
|
+
const YIELD_EVERY_N_ROWS = 500; // see the docstring inside readLines() for why this exists
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A row's `value` column comes back as a JS string when Cursor stored it as
|
|
253
|
+
* TEXT — the common case per this source's research: VS Code's storage
|
|
254
|
+
* service writes JSON via a plain JS string, and a BLOB-DECLARED column in
|
|
255
|
+
* SQLite uses "no conversion" (NONE/BLOB) affinity, meaning whatever storage
|
|
256
|
+
* class was written comes back unchanged, string in, string out. When a
|
|
257
|
+
* value WAS stored as raw bytes it comes back as a Uint8Array — verified
|
|
258
|
+
* directly against this project's own node:sqlite (NOT a Buffer, despite
|
|
259
|
+
* Node's own `sqlite` docs describing BLOB columns loosely — Buffer.isBuffer()
|
|
260
|
+
* on a real returned value here is false; checked, not assumed).
|
|
261
|
+
*/
|
|
262
|
+
function valueToText(value) {
|
|
263
|
+
if (typeof value === "string") return value;
|
|
264
|
+
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf-8");
|
|
265
|
+
return null; // NULL, an integer, or some other SQLite storage class — not text content
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read one state.vscdb as an array of raw text "lines" — one per row's
|
|
270
|
+
* decoded value, across both known tables. Returns { lines, status,
|
|
271
|
+
* bytesRead } with the same status vocabulary as claude-code.js's
|
|
272
|
+
* readLines(): "complete", "partial", "too-large", "failed".
|
|
273
|
+
*
|
|
274
|
+
* node:sqlite's DatabaseSync/StatementSync are fully SYNCHRONOUS — unlike
|
|
275
|
+
* claude-code.js's stream-based read, there is no 'error'/'close' event and
|
|
276
|
+
* no AbortSignal to hook a real preemptive timeout onto; once a native call
|
|
277
|
+
* has started, a JS-level setTimeout cannot interrupt it. What CAN be done
|
|
278
|
+
* without adding a dependency: iterate row-by-row via
|
|
279
|
+
* StatementSync#iterate() (confirmed to exist and work on this project's
|
|
280
|
+
* node:sqlite floor — Node 22.5+, per the module docstring above) and
|
|
281
|
+
* explicitly yield to the event loop every YIELD_EVERY_N_ROWS rows, checking
|
|
282
|
+
* a wall-clock deadline at each yield point. This bounds the failure mode
|
|
283
|
+
* that is actually plausible for this source — a cursorDiskKV table with
|
|
284
|
+
* tens of thousands of rows (a real install inspected during this source's
|
|
285
|
+
* research had roughly 50,000) taking too long — at the cost of NOT bounding
|
|
286
|
+
* a single pathologically large row's own decode time. That is the same
|
|
287
|
+
* asymmetry claude-code.js's own docstring admits for peak memory: a real,
|
|
288
|
+
* named limitation, not a silent gap.
|
|
289
|
+
*/
|
|
290
|
+
async function readLines(file) {
|
|
291
|
+
// Unconditional — not gated on userDirExists() the way available() is.
|
|
292
|
+
// readLines() must work for whatever file path is actually handed to it
|
|
293
|
+
// (including, e.g., a test fixture living outside Cursor's real directory
|
|
294
|
+
// entirely), matching claude-code.js's readLines() never checking its own
|
|
295
|
+
// available() either. In the real scan path this call reuses the already-
|
|
296
|
+
// cached result from the available() check that gated this source in.
|
|
297
|
+
const DB = getDatabaseSync();
|
|
298
|
+
if (!DB) return { lines: [], status: "failed", bytesRead: 0 };
|
|
299
|
+
|
|
300
|
+
let stat;
|
|
301
|
+
try { stat = fs.statSync(file); }
|
|
302
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
303
|
+
if (stat.size > MAX_DB_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
304
|
+
|
|
305
|
+
let db;
|
|
306
|
+
try {
|
|
307
|
+
db = new DB(file, { readOnly: true });
|
|
308
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
309
|
+
} catch {
|
|
310
|
+
// Covers: the file was deleted between files() and this call, a
|
|
311
|
+
// corrupt/non-SQLite file sitting at this path, or Cursor holding a lock
|
|
312
|
+
// this readonly open can't get past even within BUSY_TIMEOUT_MS. All
|
|
313
|
+
// three are genuinely "could not read this," not "read it, found
|
|
314
|
+
// nothing" — status "failed" is what keeps scan.js's report honest about
|
|
315
|
+
// the difference (see CONTRIBUTING.md rule 5).
|
|
316
|
+
return { lines: [], status: "failed", bytesRead: 0 };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const lines = [];
|
|
320
|
+
let bytesRead = 0;
|
|
321
|
+
const deadline = Date.now() + READ_TIMEOUT_MS;
|
|
322
|
+
let timedOut = false;
|
|
323
|
+
let sawError = false;
|
|
324
|
+
let foundAnyTable = false;
|
|
325
|
+
|
|
326
|
+
for (const table of ["ItemTable", "cursorDiskKV"]) {
|
|
327
|
+
let rows;
|
|
328
|
+
try {
|
|
329
|
+
rows = db.prepare(`SELECT key, value FROM ${table}`).iterate();
|
|
330
|
+
} catch {
|
|
331
|
+
// This particular table genuinely doesn't exist in this file's schema
|
|
332
|
+
// (older/newer Cursor version) — not a read failure for the OTHER
|
|
333
|
+
// table, so just move on rather than aborting the whole file.
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
foundAnyTable = true;
|
|
337
|
+
|
|
338
|
+
let n = 0;
|
|
339
|
+
try {
|
|
340
|
+
for (const row of rows) {
|
|
341
|
+
const text = valueToText(row.value);
|
|
342
|
+
// One database row becomes one scanned "line," using the value
|
|
343
|
+
// exactly as stored — not re-serialized through JSON.parse/stringify
|
|
344
|
+
// — for the same reason scan.js matches raw text rather than parsed
|
|
345
|
+
// fields: that keeps every byte the regexes depend on (quoting,
|
|
346
|
+
// escaping, control characters) exactly as Cursor wrote it.
|
|
347
|
+
if (text) { lines.push(text); bytesRead += Buffer.byteLength(text, "utf-8"); }
|
|
348
|
+
n++;
|
|
349
|
+
if (n % YIELD_EVERY_N_ROWS === 0) {
|
|
350
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
351
|
+
if (Date.now() > deadline) { timedOut = true; break; }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// A row iterator can itself throw partway (e.g. a corrupted page hit
|
|
356
|
+
// mid-scan) — whatever WAS read before that is real content, kept the
|
|
357
|
+
// same way claude-code.js keeps a partial read rather than discarding it.
|
|
358
|
+
sawError = true;
|
|
359
|
+
}
|
|
360
|
+
if (timedOut) break;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
try { db.close(); } catch { /* best-effort close; nothing left to do if this fails */ }
|
|
364
|
+
|
|
365
|
+
// Neither known table existed at all — this file opened fine as SQLite but
|
|
366
|
+
// didn't match the schema this source understands, which is a real "could
|
|
367
|
+
// not extract anything," not the same as "extracted zero real rows."
|
|
368
|
+
if (!foundAnyTable) return { lines: [], status: "failed", bytesRead: 0 };
|
|
369
|
+
if (sawError && lines.length === 0) return { lines: [], status: "failed", bytesRead };
|
|
370
|
+
if (timedOut || sawError) return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
371
|
+
return { lines, status: "complete", bytesRead };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
module.exports = { id, label, available, unavailableReason, files, readLines };
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cognition's Devin CLI ("Devin for Terminal," docs.devin.ai/work-with-devin/
|
|
9
|
+
* devin-cli) local session database.
|
|
10
|
+
*
|
|
11
|
+
* VERIFICATION STATUS: corroborated by two independent sources — one of them
|
|
12
|
+
* real, working, tested code that reads the exact file this source targets —
|
|
13
|
+
* but NOT checked against a real install on the machine this source was
|
|
14
|
+
* built on (no `~/.local/share/devin` directory exists there, and Devin CLI
|
|
15
|
+
* was not installed to create one; see CONTRIBUTING.md). Cognition's own docs
|
|
16
|
+
* (docs.devin.ai/cli/reference/commands) confirm Devin CLI is a real local
|
|
17
|
+
* terminal agent — full local file access, no cloud workspace copy — with a
|
|
18
|
+
* `man devin` page and a `--config <PATH>` flag, but do not themselves state
|
|
19
|
+
* the storage path or schema, which is why this source leans on the two
|
|
20
|
+
* sources below instead of Cognition's own docs for those specifics.
|
|
21
|
+
*
|
|
22
|
+
* 1. github.com/fabzter/devin-session-search — a real, working MCP server
|
|
23
|
+
* (25 passing tests) built specifically to full-text-search "Devin
|
|
24
|
+
* CLI's SQLite-based session store." Its README states plainly: "Devin
|
|
25
|
+
* CLI stores every conversation in `~/.local/share/devin/cli/
|
|
26
|
+
* sessions.db`," and its architecture diagram documents the schema this
|
|
27
|
+
* source relies on: a `sessions` table (id, title, model, created_at)
|
|
28
|
+
* and a `message_nodes` table ("4,323+ rows of conversation content")
|
|
29
|
+
* whose `chat_message` column holds a JSON blob of
|
|
30
|
+
* `{role, content, tool_calls}`. `indexer.py`'s own source (fetched
|
|
31
|
+
* during this source's research) opens `sessions.db` with
|
|
32
|
+
* `sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)` — i.e. this
|
|
33
|
+
* tool's author built and tested a read-only reader against the real
|
|
34
|
+
* file, not a description of one.
|
|
35
|
+
* 2. jazzyalex/agent-sessions (github.com/jazzyalex/agent-sessions, 800+
|
|
36
|
+
* stars, a real macOS app built to parse local AI-coding-agent session
|
|
37
|
+
* history) independently lists Devin CLI as its "fourteenth agent
|
|
38
|
+
* source," describing it as reading "from the shared SQLite
|
|
39
|
+
* `sessions.db` under the CLI data directory," consistent with source 1
|
|
40
|
+
* above, and separately notes "Resume verified 2026-08-27 on 3000.5.20"
|
|
41
|
+
* — i.e. its maintainer ran this against a real, current Devin CLI
|
|
42
|
+
* install.
|
|
43
|
+
*
|
|
44
|
+
* Neither source states a macOS-specific path (both use the Linux/XDG-style
|
|
45
|
+
* `~/.local/share/...` unconditionally, with no OS branching visible in
|
|
46
|
+
* source 1's own code) — this source follows that exactly rather than
|
|
47
|
+
* guessing at a `~/Library/Application Support/devin` variant no source
|
|
48
|
+
* describes; if Devin CLI does branch by OS on a real Mac, this source will
|
|
49
|
+
* correctly report "not available" there rather than silently checking the
|
|
50
|
+
* wrong path (see available() below).
|
|
51
|
+
*
|
|
52
|
+
* Column names beyond the two table names above are not hard-relied upon:
|
|
53
|
+
* like cursor.js's ItemTable/cursorDiskKV handling, this source selects every
|
|
54
|
+
* column of every row (`SELECT *`) and turns each row into one JSON-
|
|
55
|
+
* stringified scanned "line," rather than hard-coding e.g. `chat_message` as
|
|
56
|
+
* the only column worth reading — a schema field this source's research
|
|
57
|
+
* didn't happen to name (an API key column, say) is not silently skipped.
|
|
58
|
+
*/
|
|
59
|
+
const HOME = os.homedir();
|
|
60
|
+
const SESSIONS_DB = path.join(HOME, ".local", "share", "devin", "cli", "sessions.db");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* node:sqlite is a Node CORE module, not a package — see cursor.js's
|
|
64
|
+
* docstring for the full reasoning on why this is lazy-required (avoiding an
|
|
65
|
+
* ExperimentalWarning on every `residoo scan` for users who don't have Devin
|
|
66
|
+
* CLI installed) and why that reasoning matters project-wide, not just for
|
|
67
|
+
* Cursor.
|
|
68
|
+
*/
|
|
69
|
+
const NODE_SQLITE_REQUIREMENT = "needs Node.js 22.5+ (node:sqlite not present in this runtime)";
|
|
70
|
+
let sqliteRequireAttempted = false;
|
|
71
|
+
let DatabaseSync = null;
|
|
72
|
+
|
|
73
|
+
function getDatabaseSync() {
|
|
74
|
+
if (!sqliteRequireAttempted) {
|
|
75
|
+
sqliteRequireAttempted = true;
|
|
76
|
+
try { ({ DatabaseSync } = require("node:sqlite")); }
|
|
77
|
+
catch { DatabaseSync = null; }
|
|
78
|
+
}
|
|
79
|
+
return DatabaseSync;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function id() { return "devin-cli"; }
|
|
83
|
+
function label() { return "Devin CLI"; }
|
|
84
|
+
|
|
85
|
+
function dbFileExists() {
|
|
86
|
+
try { return fs.statSync(SESSIONS_DB).isFile(); } catch { return false; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function available() {
|
|
90
|
+
// Cheap fs check first — see cursor.js's available() for why this ordering
|
|
91
|
+
// matters (skip requiring node:sqlite, and its warning, for the common
|
|
92
|
+
// case of Devin CLI simply not being installed).
|
|
93
|
+
return dbFileExists() && Boolean(getDatabaseSync());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Same "why this exists" as cursor.js's unavailableReason(): distinguishes
|
|
98
|
+
* "Devin CLI is installed here but this Node runtime is too old for
|
|
99
|
+
* node:sqlite" from the ordinary, unremarkable "Devin CLI just isn't here."
|
|
100
|
+
* Optional per the source contract — only cli.js's own diagnostics call this.
|
|
101
|
+
*/
|
|
102
|
+
function unavailableReason() {
|
|
103
|
+
if (!dbFileExists()) return null;
|
|
104
|
+
if (getDatabaseSync()) return null;
|
|
105
|
+
return `Devin CLI detected but not scanned — ${NODE_SQLITE_REQUIREMENT}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A single fixed file, not a directory walk — mirrors cursor.js's
|
|
110
|
+
* statIfPresent() for GLOBAL_STORAGE_DB. Not `broken: true` when the path
|
|
111
|
+
* simply doesn't exist (the ordinary "Devin CLI isn't installed, or hasn't
|
|
112
|
+
* created a session yet" case); that's reserved for a path that looked like
|
|
113
|
+
* it should resolve to a real file and didn't (a dangling symlink).
|
|
114
|
+
*/
|
|
115
|
+
function* files() {
|
|
116
|
+
let lst;
|
|
117
|
+
try { lst = fs.lstatSync(SESSIONS_DB); }
|
|
118
|
+
catch { return; }
|
|
119
|
+
|
|
120
|
+
if (lst.isSymbolicLink()) {
|
|
121
|
+
try {
|
|
122
|
+
const st = fs.statSync(SESSIONS_DB);
|
|
123
|
+
if (!st.isFile()) { yield { file: SESSIONS_DB, broken: true }; return; }
|
|
124
|
+
yield { file: SESSIONS_DB, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
125
|
+
} catch {
|
|
126
|
+
yield { file: SESSIONS_DB, broken: true };
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!lst.isFile()) return; // something unexpected sits at this path — out of scope, not broken
|
|
132
|
+
yield { file: SESSIONS_DB, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const MAX_DB_BYTES = 512 * 1024 * 1024; // generous backstop; no real Devin sessions.db size was
|
|
136
|
+
// observed during this source's research (see cursor.js's
|
|
137
|
+
// identical caveat about its own MAX_DB_BYTES).
|
|
138
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
139
|
+
const BUSY_TIMEOUT_MS = 5_000;
|
|
140
|
+
const YIELD_EVERY_N_ROWS = 500;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Turn one row (a plain object from node:sqlite's StatementSync#iterate())
|
|
144
|
+
* into one scanned text "line." node:sqlite returns a BLOB column as a
|
|
145
|
+
* Uint8Array (decoded to UTF-8 text here, same as cursor.js's valueToText)
|
|
146
|
+
* and an INTEGER too large for a safe JS number as a BigInt (which
|
|
147
|
+
* JSON.stringify throws on unless handled) — both are normalized before
|
|
148
|
+
* stringifying so a row is never silently dropped for containing either.
|
|
149
|
+
*/
|
|
150
|
+
function rowToLine(row) {
|
|
151
|
+
const normalized = {};
|
|
152
|
+
for (const [key, value] of Object.entries(row)) {
|
|
153
|
+
if (typeof value === "bigint") normalized[key] = value.toString();
|
|
154
|
+
else if (value instanceof Uint8Array) normalized[key] = Buffer.from(value).toString("utf-8");
|
|
155
|
+
else normalized[key] = value;
|
|
156
|
+
}
|
|
157
|
+
try { return JSON.stringify(normalized); }
|
|
158
|
+
catch { return null; } // e.g. a cyclic or otherwise unstringifiable value — skip this row, not the file
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Read sessions.db as an array of raw text "lines" — one per row, across
|
|
163
|
+
* both known tables. Same status vocabulary, same row-by-row-with-a-
|
|
164
|
+
* wall-clock-deadline approach, and same reasoning for all of it as
|
|
165
|
+
* cursor.js's readLines() — see that file's docstring; not repeated in full
|
|
166
|
+
* here since the mechanics are identical, only the table/column names
|
|
167
|
+
* differ.
|
|
168
|
+
*/
|
|
169
|
+
async function readLines(file) {
|
|
170
|
+
const DB = getDatabaseSync();
|
|
171
|
+
if (!DB) return { lines: [], status: "failed", bytesRead: 0 };
|
|
172
|
+
|
|
173
|
+
let stat;
|
|
174
|
+
try { stat = fs.statSync(file); }
|
|
175
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
176
|
+
if (stat.size > MAX_DB_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
177
|
+
|
|
178
|
+
let db;
|
|
179
|
+
try {
|
|
180
|
+
// readBigInts: true matters here in a way it happens not to for cursor.js's
|
|
181
|
+
// reference implementation — that source only ever SELECTs a fixed `key
|
|
182
|
+
// TEXT, value BLOB` pair, so no column can hold an INTEGER SQLite would
|
|
183
|
+
// need to widen. This source does `SELECT *` against an externally-owned,
|
|
184
|
+
// only-partially-documented schema (see the module docstring) where an
|
|
185
|
+
// INTEGER column — a nanosecond-epoch `created_at`, say, which routinely
|
|
186
|
+
// exceeds 2^53 — is a real possibility. Without this flag, node:sqlite
|
|
187
|
+
// THROWS RangeError the moment it meets such a value mid-iteration
|
|
188
|
+
// (verified directly against this project's own node:sqlite while
|
|
189
|
+
// building this source), which without care would silently drop that row
|
|
190
|
+
// and everything after it in the same table — exactly the "schema
|
|
191
|
+
// surprise swallowed by a bare catch" failure this project exists to
|
|
192
|
+
// avoid (see CONTRIBUTING.md rule 5). With it, the same value comes back
|
|
193
|
+
// as a BigInt, which rowToLine() below already converts to a string.
|
|
194
|
+
db = new DB(file, { readOnly: true, readBigInts: true });
|
|
195
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
196
|
+
} catch {
|
|
197
|
+
return { lines: [], status: "failed", bytesRead: 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const lines = [];
|
|
201
|
+
let bytesRead = 0;
|
|
202
|
+
const deadline = Date.now() + READ_TIMEOUT_MS;
|
|
203
|
+
let timedOut = false;
|
|
204
|
+
let sawError = false;
|
|
205
|
+
let foundAnyTable = false;
|
|
206
|
+
|
|
207
|
+
for (const table of ["sessions", "message_nodes"]) {
|
|
208
|
+
let rows;
|
|
209
|
+
try {
|
|
210
|
+
rows = db.prepare(`SELECT * FROM ${table}`).iterate();
|
|
211
|
+
} catch {
|
|
212
|
+
continue; // this table doesn't exist in this file's schema — try the other one
|
|
213
|
+
}
|
|
214
|
+
foundAnyTable = true;
|
|
215
|
+
|
|
216
|
+
let n = 0;
|
|
217
|
+
try {
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
const text = rowToLine(row);
|
|
220
|
+
if (text) { lines.push(text); bytesRead += Buffer.byteLength(text, "utf-8"); }
|
|
221
|
+
n++;
|
|
222
|
+
if (n % YIELD_EVERY_N_ROWS === 0) {
|
|
223
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
224
|
+
if (Date.now() > deadline) { timedOut = true; break; }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
sawError = true;
|
|
229
|
+
}
|
|
230
|
+
if (timedOut) break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try { db.close(); } catch { /* best-effort close */ }
|
|
234
|
+
|
|
235
|
+
if (!foundAnyTable) return { lines: [], status: "failed", bytesRead: 0 };
|
|
236
|
+
if (sawError && lines.length === 0) return { lines: [], status: "failed", bytesRead };
|
|
237
|
+
if (timedOut || sawError) return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
238
|
+
return { lines, status: "complete", bytesRead };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { id, label, available, unavailableReason, files, readLines };
|