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.
Files changed (50) hide show
  1. package/README.md +225 -46
  2. package/SECURITY.md +29 -22
  3. package/package.json +1 -1
  4. package/src/cli.js +82 -16
  5. package/src/integrity.js +669 -0
  6. package/src/patterns.js +78 -5
  7. package/src/report.js +74 -7
  8. package/src/sources/agent-configs.js +308 -0
  9. package/src/sources/aider.js +361 -0
  10. package/src/sources/amazon-q.js +199 -0
  11. package/src/sources/antigravity-cli.js +155 -0
  12. package/src/sources/cline.js +208 -0
  13. package/src/sources/codebuff.js +295 -0
  14. package/src/sources/codex-cli.js +258 -0
  15. package/src/sources/cody.js +325 -0
  16. package/src/sources/continue.js +408 -0
  17. package/src/sources/copilot-chat.js +272 -0
  18. package/src/sources/copilot-cli.js +300 -0
  19. package/src/sources/crush.js +364 -0
  20. package/src/sources/cursor.js +374 -0
  21. package/src/sources/devin-cli.js +241 -0
  22. package/src/sources/factory-droid.js +153 -0
  23. package/src/sources/fx.js +136 -0
  24. package/src/sources/gemini-cli.js +242 -0
  25. package/src/sources/goose.js +366 -0
  26. package/src/sources/grok-cli.js +267 -0
  27. package/src/sources/hermes.js +282 -0
  28. package/src/sources/index.js +172 -8
  29. package/src/sources/jetbrains-ai-assistant.js +343 -0
  30. package/src/sources/jetbrains-junie.js +292 -0
  31. package/src/sources/kilo-code.js +430 -0
  32. package/src/sources/kimi-code.js +147 -0
  33. package/src/sources/kiro-cli.js +393 -0
  34. package/src/sources/kiro-ide.js +230 -0
  35. package/src/sources/llm.js +328 -0
  36. package/src/sources/mentat.js +143 -0
  37. package/src/sources/open-interpreter.js +224 -0
  38. package/src/sources/openclaw.js +218 -0
  39. package/src/sources/opencode.js +379 -0
  40. package/src/sources/openhands.js +181 -0
  41. package/src/sources/pearai.js +151 -0
  42. package/src/sources/pi-agent.js +130 -0
  43. package/src/sources/qodo-gen.js +189 -0
  44. package/src/sources/qwen-code.js +244 -0
  45. package/src/sources/roo-code.js +239 -0
  46. package/src/sources/trae.js +294 -0
  47. package/src/sources/void.js +273 -0
  48. package/src/sources/warp.js +395 -0
  49. package/src/sources/windsurf.js +256 -0
  50. 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 };