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,300 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const { createInterface } = require("readline/promises");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ /**
9
+ * GitHub Copilot CLI — the standalone `copilot` agentic terminal tool
10
+ * (npm package `@github/copilot`; also reachable as `gh copilot` once
11
+ * installed). NOT the VS Code "Copilot Chat" panel — see copilot-chat.js for
12
+ * that genuinely different product with a genuinely different storage
13
+ * format. Also NOT the older `gh-copilot` `gh` CLI extension (the
14
+ * `suggest`/`explain` command-line-suggestion tool) — that predecessor is
15
+ * unrelated and out of scope here; this adapter targets the current agentic
16
+ * CLI whose own docs are cited below.
17
+ *
18
+ * VERIFICATION STATUS (read this before trusting anything below):
19
+ * multi-source-corroborated-but-UNVERIFIED against a real install. Copilot
20
+ * CLI is not installed on the machine this adapter was built on (checked:
21
+ * no ~/.copilot, no `copilot` on PATH, no `gh extension list` entry, no
22
+ * Homebrew formula/cask installed under that name — the one local
23
+ * "copilot.rb" formula present is an unrelated AWS tool, `aws/copilot-cli`).
24
+ * Corroborating sources, most authoritative first:
25
+ *
26
+ * 1. Official GitHub Docs, "GitHub Copilot CLI configuration directory"
27
+ * (docs.github.com/en/copilot/reference/copilot-cli-reference/
28
+ * cli-config-dir-reference), fetched 2026-09-02. States plainly: default
29
+ * location is `~/.copilot` (`$HOME/.copilot`), overridable via the
30
+ * `COPILOT_HOME` environment variable. Its own directory-listing table
31
+ * names, verbatim: `session-state/` ("Session history and workspace
32
+ * data"), `command-history-state/` ("Command history data"), `logs/`
33
+ * ("Session log files", named `process-{timestamp}-{pid}.log`), and
34
+ * `session-store.db` (File — "SQLite database for cross-session data").
35
+ * Separately also names `config.json` ("automatically managed
36
+ * application state including authentication"), `mcp-secrets/`, and
37
+ * `mcp-oauth-config/` — this tool's OWN credential/token storage, not
38
+ * conversation content; deliberately excluded here, same reasoning
39
+ * claude-code.js's scope never reaches `~/.claude.json`.
40
+ * 2. Official GitHub Docs, "About GitHub Copilot CLI session data"
41
+ * (docs.github.com/en/copilot/concepts/agents/copilot-cli/chronicle),
42
+ * fetched 2026-09-02. States each session is persisted as "a set of
43
+ * files in the `~/.copilot/session-state/` directory," recording "your
44
+ * prompts, Copilot's responses, the tools that were used, and details of
45
+ * files that were modified" — i.e. exactly the transcript content this
46
+ * scanner exists to check, confirmed as living there by GitHub's own
47
+ * docs, not just inferred.
48
+ * 3. jonmagic.com/posts/github-copilot-session-search-and-resume-cli/ — a
49
+ * real practitioner's own inspection of actual files on their machine,
50
+ * independent of GitHub's docs. Confirms the concrete shape:
51
+ * `~/.copilot/session-state/<uuid>/` per session, containing
52
+ * `workspace.yaml` (metadata) and `events.jsonl` (a newline-delimited
53
+ * JSON event stream — quotes real event lines: `session.start`,
54
+ * `user.message`, `assistant.turn_start`, `tool.execution_start` with
55
+ * its `arguments`, `tool.execution_complete`, `session.shutdown`), and
56
+ * explicitly notes "some sessions have additional files such as
57
+ * `session.db`, plans, checkpoints, or VS Code metadata" that "vary by
58
+ * Copilot version" — i.e. confirms the file set is NOT a fixed, safely
59
+ * allow-listable list, which is why files() below does not hard-code one
60
+ * (same reasoning cursor.js gives for not allow-listing its own row
61
+ * keys).
62
+ * 4. dfberry.github.io/2026-04-16-session-storage-decision-guide —
63
+ * independently corroborates `session-state/<id>/events.jsonl` and
64
+ * `session-store.db`, and adds the SQLite schema: seven tables
65
+ * including "sessions, turns, checkpoints, session_files, session_refs"
66
+ * plus an FTS5 search index, and warns not to delete the `.db-wal` file
67
+ * while Copilot is running (confirming it's a live, WAL-mode SQLite
68
+ * file, not an inert export).
69
+ *
70
+ * `session-store.db`'s ROW-LEVEL schema (which column of which table, if
71
+ * any, holds raw prompt/response text vs. only derived search-index data) is
72
+ * corroborated by exactly ONE of the sources above (#4) — short of this
73
+ * project's normal two-independent-source bar for a claim precise enough to
74
+ * write a SQL query against, unlike cursor.js's table/column shape, which
75
+ * had a real live install AND multiple agreeing write-ups behind it.
76
+ * Guessing at that schema is exactly the "SQLite schema drifted, bare
77
+ * except/continue swallowed it, tool reported a full scan as clean" failure
78
+ * this cluster's brief calls out by name. `session-store.db` is therefore
79
+ * DELIBERATELY NOT queried here — the actual prompts/responses/tool-args
80
+ * this scanner cares about are independently, solidly confirmed (sources
81
+ * #2–#4 agree) to live in `session-state/**`'s plain-text files regardless,
82
+ * so this is a named, bounded gap rather than a load-bearing one. If a
83
+ * future contributor confirms the exact table/column shape against a real
84
+ * install, add it then — with that verification.
85
+ *
86
+ * `command-history-state` has been reported in TWO conflicting shapes across
87
+ * the sources above and elsewhere: the official config-dir-reference table
88
+ * (#1) lists it as a DIRECTORY (`command-history-state/`), while several
89
+ * independent write-ups (inventivehq.com's own config-file breakdown;
90
+ * rajeevpentyala.com's "[Quick Tip] GitHub Copilot CLI | Get Prompt History")
91
+ * describe a single FILE, `command-history-state.json`, holding a
92
+ * `commandHistory` array of every prompt typed. Rather than guess which is
93
+ * current, this adapter checks for BOTH shapes and scans whichever is
94
+ * actually present — the same "don't pick a side, read what's really there"
95
+ * approach cursor.js takes for its own two-possible-storage-location
96
+ * uncertainty. This is real typed-prompt content (a very plausible place for
97
+ * a pasted secret to land) and is in scope.
98
+ *
99
+ * Deliberately excluded, named rather than silently skipped: `settings.json`,
100
+ * `config.json`, `lsp-config.json`, `mcp-config.json`,
101
+ * `permissions-config.json`, `mcp-oauth-config/`, `mcp-secrets/`,
102
+ * `agents/`, `skills/`, `instructions/`, `extensions/`, `hooks/`,
103
+ * `installed-plugins/`, `plugin-data/`, `ide/`, and `session-store.db`
104
+ * (discussed above). These are Copilot CLI's OWN configuration and
105
+ * credential/token storage, not user session transcripts — scanning and
106
+ * potentially surfacing THOSE would run against residoo's own purpose, and
107
+ * mirrors claude-code.js never reaching into `~/.claude/settings.json` or
108
+ * `~/.claude.json`.
109
+ */
110
+ function copilotHome() {
111
+ const override = process.env.COPILOT_HOME;
112
+ if (override) return override; // per official docs; used as given, same trust level cursor.js gives XDG_CONFIG_HOME
113
+ return path.join(os.homedir(), ".copilot");
114
+ }
115
+
116
+ const ROOT = copilotHome();
117
+
118
+ // Storage-class extensions this adapter knows are SQLite (or its WAL/SHM/
119
+ // journal siblings) and deliberately does not treat as scannable text —
120
+ // covers both the top-level session-store.db and any per-session
121
+ // "session.db" jonmagic.com's inspection found inside session-state/<uuid>/.
122
+ // Matched case-insensitively against the file's extension only.
123
+ const BINARY_DB_EXTENSIONS = new Set([".db", ".db-wal", ".db-shm", ".db-journal", ".sqlite", ".sqlite3"]);
124
+
125
+ // Bounds for readLines() — same values as claude-code.js. Not backed by a
126
+ // real Copilot CLI transcript this tool was tested against (no install to
127
+ // test with) — see the verification-status note above.
128
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
129
+ const READ_TIMEOUT_MS = 60_000;
130
+
131
+ // Recursion guard for walking session-state/ and command-history-state/ —
132
+ // per-session subdirectories are one level deep in every source consulted,
133
+ // but the cap (and symlink-cycle de-dup) exists purely to bound a
134
+ // pathological or unexpectedly deep layout, same as windsurf.js's own walk.
135
+ const MAX_WALK_DEPTH = 12;
136
+
137
+ function id() { return "copilot-cli"; }
138
+ function label() { return "GitHub Copilot CLI"; }
139
+
140
+ function available() {
141
+ try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
142
+ }
143
+
144
+ /**
145
+ * Same defensive symlink-following pattern as claude-code.js's
146
+ * isDirFollowingSymlink/isFileFollowingSymlink — see that file's docstring
147
+ * for the full reasoning. Duplicated rather than imported, matching this
148
+ * project's "small, self-contained file" convention.
149
+ */
150
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
151
+ if (checkFn(dirent)) return true;
152
+ if (!dirent.isSymbolicLink()) return false;
153
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
154
+ }
155
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
156
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
157
+
158
+ function isBinaryDbFile(name) {
159
+ const ext = path.extname(name).toLowerCase();
160
+ return BINARY_DB_EXTENSIONS.has(ext);
161
+ }
162
+
163
+ /**
164
+ * Recursively yield { file, mtimeMs, sizeBytes, broken } for every file
165
+ * under `dir` EXCEPT ones recognized as SQLite-family by extension (see
166
+ * BINARY_DB_EXTENSIONS and the module docstring for why those are excluded
167
+ * rather than read as text). No further extension allow-listing is done —
168
+ * per jonmagic.com's own real inspection (cited above), the exact set of
169
+ * per-session files varies by Copilot CLI version, so this walks whatever is
170
+ * actually there, the same "don't hard-code a list likely to go stale"
171
+ * reasoning cursor.js applies to its own row keys.
172
+ *
173
+ * A dangling symlink (file or directory) is reported broken:true rather
174
+ * than silently skipped, matching claude-code.js's convention.
175
+ * `visitedRealDirs` de-dupes symlinked directories by resolved real path so
176
+ * a symlink cycle terminates instead of recursing forever.
177
+ */
178
+ function* walkTextFiles(dir, depth, visitedRealDirs) {
179
+ if (depth > MAX_WALK_DEPTH) return;
180
+
181
+ let entries;
182
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
183
+ catch { return; } // this directory doesn't exist here — normal for an unused feature, not broken
184
+
185
+ for (const e of entries) {
186
+ const full = path.join(dir, e.name);
187
+
188
+ if (isDirFollowingSymlink(full, e)) {
189
+ if (e.isSymbolicLink()) {
190
+ let real;
191
+ try { real = fs.realpathSync(full); }
192
+ catch { yield { file: full, broken: true }; continue; }
193
+ if (visitedRealDirs.has(real)) continue;
194
+ visitedRealDirs.add(real);
195
+ }
196
+ yield* walkTextFiles(full, depth + 1, visitedRealDirs);
197
+ continue;
198
+ }
199
+
200
+ if (isBinaryDbFile(e.name)) continue; // known SQLite-family file — deliberately out of scope, see docstring
201
+
202
+ if (!isFileFollowingSymlink(full, e)) {
203
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
204
+ continue;
205
+ }
206
+
207
+ let stat;
208
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
209
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Resolve the single-file shape of command-history-state
215
+ * (~/.copilot/command-history-state.json — see docstring for why both
216
+ * shapes are checked) into zero or one files() entries. Same lstat-based
217
+ * logic and broken-vs-absent convention as cursor.js's statIfPresent().
218
+ */
219
+ function* statIfPresentFile(filePath) {
220
+ let lst;
221
+ try { lst = fs.lstatSync(filePath); }
222
+ catch { return; }
223
+
224
+ if (lst.isSymbolicLink()) {
225
+ try {
226
+ const st = fs.statSync(filePath);
227
+ if (!st.isFile()) { yield { file: filePath, broken: true }; return; }
228
+ yield { file: filePath, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
229
+ } catch {
230
+ yield { file: filePath, broken: true };
231
+ }
232
+ return;
233
+ }
234
+
235
+ if (!lst.isFile()) return;
236
+ yield { file: filePath, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
237
+ }
238
+
239
+ /**
240
+ * Yield { file, mtimeMs, sizeBytes, broken } for every candidate transcript
241
+ * file this adapter knows how to find under $COPILOT_HOME (default
242
+ * ~/.copilot): every non-SQLite file under session-state/ and under
243
+ * command-history-state/ (if it exists as a directory), the single
244
+ * command-history-state.json file (if it exists instead, as a file), and
245
+ * every non-SQLite file under logs/.
246
+ */
247
+ function* files() {
248
+ yield* walkTextFiles(path.join(ROOT, "session-state"), 0, new Set());
249
+ yield* walkTextFiles(path.join(ROOT, "command-history-state"), 0, new Set());
250
+ yield* statIfPresentFile(path.join(ROOT, "command-history-state.json"));
251
+ yield* walkTextFiles(path.join(ROOT, "logs"), 0, new Set());
252
+ }
253
+
254
+ /**
255
+ * Read one file as an array of raw text lines.
256
+ *
257
+ * Every format this adapter's files() can yield is real UTF-8 text by
258
+ * construction: `events.jsonl` is one JSON record per line (confirmed
259
+ * directly from jonmagic.com's quoted real event lines), `workspace.yaml`
260
+ * and `command-history-state.json`/`.../*.json` are ordinary structured
261
+ * text, and `logs/process-*.log` are plain log files. The same streamed
262
+ * readline/promises approach claude-code.js and cline.js use applies
263
+ * unchanged here — no whole-file-as-one-string V8 length ceiling, and a
264
+ * partial read still returns whatever lines WERE read rather than
265
+ * discarding real content. Status vocabulary matches every other source in
266
+ * this project: "complete", "partial", "too-large", "failed".
267
+ */
268
+ async function readLines(file) {
269
+ let stat;
270
+ try { stat = fs.statSync(file); }
271
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
272
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
273
+
274
+ const lines = [];
275
+ let bytesRead = 0;
276
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
277
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
278
+
279
+ // Same rationale as claude-code.js: no natural timeout exists anywhere in
280
+ // Node's stream/readline stack, and a retargeted symlink can make the
281
+ // underlying open() block forever with no event ever firing. Destroying
282
+ // the stream is what actually unblocks that.
283
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
284
+
285
+ try {
286
+ for await (const line of rl) {
287
+ lines.push(line);
288
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
289
+ }
290
+ return { lines, status: "complete", bytesRead };
291
+ } catch {
292
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
293
+ } finally {
294
+ clearTimeout(timer);
295
+ rl.close();
296
+ stream.destroy();
297
+ }
298
+ }
299
+
300
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,364 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+
7
+ /**
8
+ * Crush (charmbracelet/crush) session/chat history.
9
+ *
10
+ * VERIFICATION STATUS (read this before trusting anything below): this
11
+ * source is corroborated by the actual current Go source code of the
12
+ * official charmbracelet/crush repository on GitHub — fetched and read
13
+ * directly from the `main` branch (goose migration SQL, the config/data-dir
14
+ * resolution code, and the project-registry code), not inferred from a
15
+ * description of it. It has NOT been checked against a real Crush install —
16
+ * Crush is not installed on the machine this adapter was built on (checked:
17
+ * no `crush` on PATH, no `~/.local/share/crush` directory, not in `brew
18
+ * list`, no `crush`-named entry anywhere findable via `mdfind`). If you have
19
+ * Crush installed, the most useful thing you can do is run `residoo scan`
20
+ * and confirm `sourcesScanned`/`filesScanned` look right for what you know
21
+ * is actually on disk (`crush dirs` prints Crush's own view of its config
22
+ * and data locations), then report back either way.
23
+ *
24
+ * STORAGE MODEL — genuinely different from most sources in this project, so
25
+ * spelled out in full:
26
+ *
27
+ * Crush keeps its SQLite database PER PROJECT, the same way git keeps a
28
+ * `.git` directory per repo, not in one global well-known folder. Confirmed
29
+ * directly in source (`internal/config/config.go`, the `Options.DataDirectory`
30
+ * field doc comment): "DataDirectory is where Crush keeps per-project state
31
+ * such as the SQLite database and workspace overrides. Relative paths are
32
+ * resolved against the working directory; absolute paths are used as-is,"
33
+ * default `.crush`. `internal/db/connect.go` confirms the filename:
34
+ * `dbPath := filepath.Join(dataDir, "crush.db")`.
35
+ *
36
+ * So there is no single directory to list. What Crush DOES keep centrally is
37
+ * a small JSON registry of every project it has ever been run against —
38
+ * confirmed directly in `internal/projects/projects.go`:
39
+ * - `projectsFilePath()` returns
40
+ * `filepath.Join(filepath.Dir(config.GlobalConfigData()), "projects.json")`.
41
+ * - Schema: `{"projects":[{"path":"...","data_dir":"...","last_accessed":"..."}]}`
42
+ * (the `Project`/`ProjectList` structs, same file).
43
+ * - `internal/cmd/root.go` calls `projects.Register(cwd, cfg.Options.DataDirectory)`
44
+ * unconditionally on every normal `crush` invocation, immediately before
45
+ * `db.Connect(ctx, cfg.Options.DataDirectory)` — so any project that has
46
+ * ever actually written a `crush.db` is, in the overwhelmingly common
47
+ * case, also listed here (failure to register is logged and non-fatal,
48
+ * so a very unlucky write failure could in principle desync the two —
49
+ * acknowledged, not fixable from outside the tool).
50
+ * - Crush's own `stats --all` command reads this exact file to aggregate
51
+ * across projects (`internal/cmd/stats.go`,
52
+ * `gatherStatsFromProjects()` → `projects.Load()` →
53
+ * `filepath.Join(p.DataDir, "crush.db")`) — this source's files() does
54
+ * the same walk. Crush also ships a `stats --crawl-dir` mode that does a
55
+ * full recursive filesystem walk for orphaned `.crush/crush.db`
56
+ * directories instead; this source deliberately does NOT replicate that
57
+ * — an unbounded home-directory crawl is a different cost/risk profile
58
+ * than every other source in this project, and Crush's own default
59
+ * behavior (registry-based, not crawl-based) is the one this mirrors.
60
+ * A `crush.db` from a project Crush was never actually run `cd`-first
61
+ * into (registration skipped some other way) would be missed; that is a
62
+ * real, named gap, not a silent one.
63
+ *
64
+ * `GlobalConfigData()`'s directory (`internal/config/load.go`) — resolved in
65
+ * this exact order, replicated in `crushGlobalDataDir()` below:
66
+ * 1. `$CRUSH_GLOBAL_DATA` if set, used as-is (no `crush` suffix appended).
67
+ * 2. `$XDG_DATA_HOME/crush` if `XDG_DATA_HOME` is set.
68
+ * 3. Windows: `%LOCALAPPDATA%\crush` (`LOCALAPPDATA` env, falling back to
69
+ * `%USERPROFILE%\AppData\Local`).
70
+ * 4. Otherwise (macOS AND Linux — confirmed no Darwin special-case in
71
+ * source, unlike Cursor): `$HOME/.local/share/crush`.
72
+ *
73
+ * DB schema, confirmed directly from the goose migration files under
74
+ * `internal/db/migrations/`: `20250424200609_initial.sql` creates `sessions`
75
+ * (id, title, message_count, prompt/completion tokens, cost, timestamps) and
76
+ * `messages` (id, session_id, role, `parts` TEXT — a JSON array of message
77
+ * content parts — model, timestamps) and `files` (id, session_id, path,
78
+ * `content` TEXT, version, timestamps — full-content snapshots of files the
79
+ * agent read or wrote during a session, kept for diffing/undo). Later
80
+ * migrations only ADD columns to `messages`/`sessions`
81
+ * (`20250627000000_add_provider_to_messages.sql`,
82
+ * `20250810000000_add_is_summary_message.sql`,
83
+ * `20250812000000_add_todos_to_sessions.sql`) or add an unrelated
84
+ * path-only `read_files` table (`20260127000000_add_read_files_table.sql` —
85
+ * session_id/path/read_at, no content, not scanned). `internal/message/
86
+ * content.go` confirms `parts` holds real plaintext — `TextContent{Text
87
+ * string}`, `ReasoningContent{Thinking string}` — not an opaque or encrypted
88
+ * blob. `internal/db/connect.go`'s pragmas (`journal_mode=WAL`,
89
+ * `secure_delete=ON`, ordinary `busy_timeout`) show nothing about
90
+ * encryption; nothing in source opens this database with a key.
91
+ *
92
+ * This source reads BOTH `messages` and `files` — both hold real content a
93
+ * secret could be sitting in (a pasted token in a chat turn; a `.env` file
94
+ * the agent happened to read, whose full text `files` snapshots verbatim).
95
+ * `sessions` (title/todos only) and `read_files` (paths/timestamps only) are
96
+ * left out as low-value scope creep, same judgment call gemini-cli.js
97
+ * documents for its own out-of-scope directories. Each table is read via
98
+ * `SELECT * FROM <table>` with no column allowlist — deliberately, same
99
+ * reasoning as cursor.js: column additions (already observed twice above)
100
+ * should not require an adapter update to keep being scanned.
101
+ *
102
+ * `internal/home/home.go` confirms `home.Dir()` is exactly
103
+ * `os.UserHomeDir()` — no XDG override on the home directory itself, matching
104
+ * Node's `os.homedir()`.
105
+ *
106
+ * One secondary source was also checked — Vercel's AI Gateway docs page on
107
+ * Crush — and is worth naming the gap in: it describes the database as
108
+ * simply living in `~/.local/share/crush/`. Direct source inspection shows
109
+ * that is imprecise: that directory holds `crush.json` (config) and
110
+ * `projects.json` (the registry this source actually reads); each project's
111
+ * real `crush.db` lives at the `data_dir` THAT FILE points to, by default
112
+ * `<project>/.crush/crush.db`. This is exactly the kind of guessed-path
113
+ * error CONTRIBUTING.md warns about, caught only by going to source instead
114
+ * of trusting the paraphrase.
115
+ */
116
+ function crushGlobalDataDir() {
117
+ const crushGlobalData = process.env.CRUSH_GLOBAL_DATA;
118
+ if (crushGlobalData) return crushGlobalData; // used as-is, per GlobalConfigData()
119
+ const xdgDataHome = process.env.XDG_DATA_HOME;
120
+ if (xdgDataHome) return path.join(xdgDataHome, "crush");
121
+ if (process.platform === "win32") {
122
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
123
+ return path.join(localAppData, "crush");
124
+ }
125
+ return path.join(os.homedir(), ".local", "share", "crush");
126
+ }
127
+
128
+ const GLOBAL_DATA_DIR = crushGlobalDataDir();
129
+ const PROJECTS_REGISTRY = path.join(GLOBAL_DATA_DIR, "projects.json");
130
+
131
+ /**
132
+ * Same lazy-require, feature-detected node:sqlite pattern as cursor.js — see
133
+ * that file's docstring for the full reasoning (an eager top-level require
134
+ * would print Node's ExperimentalWarning on every `residoo scan` for every
135
+ * user, even the majority who have never touched Crush). Duplicated rather
136
+ * than shared: each source here is meant to be a small, self-contained file
137
+ * a reviewer can audit on its own (CONTRIBUTING.md).
138
+ */
139
+ const NODE_SQLITE_REQUIREMENT = "needs Node.js 22.5+ (node:sqlite not present in this runtime)";
140
+ let sqliteRequireAttempted = false;
141
+ let DatabaseSync = null;
142
+
143
+ function getDatabaseSync() {
144
+ if (!sqliteRequireAttempted) {
145
+ sqliteRequireAttempted = true;
146
+ try { ({ DatabaseSync } = require("node:sqlite")); }
147
+ catch { DatabaseSync = null; }
148
+ }
149
+ return DatabaseSync;
150
+ }
151
+
152
+ function id() { return "crush"; }
153
+ function label() { return "Crush"; }
154
+
155
+ function globalDataDirExists() {
156
+ try { return fs.statSync(GLOBAL_DATA_DIR).isDirectory(); } catch { return false; }
157
+ }
158
+
159
+ function available() {
160
+ // Cheap fs check first, same short-circuit reasoning as cursor.js's
161
+ // available(): the common case is Crush simply isn't installed, and that
162
+ // must not cost requiring node:sqlite.
163
+ return globalDataDirExists() && Boolean(getDatabaseSync());
164
+ }
165
+
166
+ /**
167
+ * Same additive, optional export as cursor.js's unavailableReason() — see
168
+ * that file's docstring. Distinguishes "Crush isn't here" (say nothing) from
169
+ * "Crush is here but this Node runtime can't read its database" (say so).
170
+ */
171
+ function unavailableReason() {
172
+ if (!globalDataDirExists()) return null;
173
+ if (getDatabaseSync()) return null;
174
+ return `Crush detected but not scanned — ${NODE_SQLITE_REQUIREMENT}`;
175
+ }
176
+
177
+ /**
178
+ * Resolve one candidate `crush.db` path into zero or one files() entries.
179
+ * Identical in shape and reasoning to cursor.js's statIfPresent(): these
180
+ * paths are constructed from the projects.json registry, not discovered by
181
+ * listing a directory, so lstat is used directly rather than readdirSync's
182
+ * Dirent. A path that simply does not exist (a project registered, then
183
+ * later deleted, or a data_dir that was never actually written to) yields
184
+ * nothing and is NOT broken; a dangling symlink IS.
185
+ */
186
+ function* statIfPresent(dbPath) {
187
+ let lst;
188
+ try { lst = fs.lstatSync(dbPath); }
189
+ catch { return; }
190
+
191
+ if (lst.isSymbolicLink()) {
192
+ try {
193
+ const st = fs.statSync(dbPath); // follow the link
194
+ if (!st.isFile()) { yield { file: dbPath, broken: true }; return; }
195
+ yield { file: dbPath, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
196
+ } catch {
197
+ yield { file: dbPath, broken: true }; // dangling symlink
198
+ }
199
+ return;
200
+ }
201
+
202
+ if (!lst.isFile()) return; // something unexpected sits at this path — out of scope, not broken
203
+ yield { file: dbPath, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
204
+ }
205
+
206
+ /**
207
+ * Yield { file, mtimeMs, sizeBytes, broken } for every project's crush.db
208
+ * listed in the projects.json registry (see module docstring for exactly
209
+ * what that registry is and isn't guaranteed to contain).
210
+ *
211
+ * A missing registry (Crush never run, or a version old enough not to write
212
+ * one) yields nothing — the ordinary "nothing to scan yet" case, not broken.
213
+ * A registry that exists but fails to parse as JSON IS reported broken: were
214
+ * this silently skipped, a corrupt registry would make a real, populated
215
+ * install scan as cleanly empty, exactly the false "all clear" CONTRIBUTING.md
216
+ * rule 5 exists to prevent.
217
+ */
218
+ function* files() {
219
+ let raw;
220
+ try { raw = fs.readFileSync(PROJECTS_REGISTRY, "utf-8"); }
221
+ catch { return; }
222
+
223
+ let parsed;
224
+ try { parsed = JSON.parse(raw); }
225
+ catch { yield { file: PROJECTS_REGISTRY, broken: true }; return; }
226
+
227
+ const projects = Array.isArray(parsed && parsed.projects) ? parsed.projects : [];
228
+ const seen = new Set();
229
+
230
+ for (const p of projects) {
231
+ if (!p || typeof p.data_dir !== "string" || p.data_dir === "") continue;
232
+ // Registered data_dir is documented to always be stored absolute
233
+ // (config.go: "After defaulting the stored value is always absolute").
234
+ // Resolved against the project's own path as a defensive fallback only,
235
+ // in case an older/foreign registry entry ever violates that invariant.
236
+ const dataDir = path.isAbsolute(p.data_dir)
237
+ ? p.data_dir
238
+ : path.resolve(typeof p.path === "string" ? p.path : GLOBAL_DATA_DIR, p.data_dir);
239
+ const dbPath = path.join(dataDir, "crush.db");
240
+ if (seen.has(dbPath)) continue;
241
+ seen.add(dbPath);
242
+ yield* statIfPresent(dbPath);
243
+ }
244
+ }
245
+
246
+ // A crush.db this large has not been observed anywhere in this source's
247
+ // research — no real install was available to produce or measure one. Same
248
+ // honest caveat as gemini-cli.js's MAX_BYTES: a generous, untested backstop
249
+ // against a pathological file, not evidence of what real databases look like.
250
+ const MAX_DB_BYTES = 512 * 1024 * 1024;
251
+ const READ_TIMEOUT_MS = 60_000;
252
+ const BUSY_TIMEOUT_MS = 5_000;
253
+ const YIELD_EVERY_N_ROWS = 500;
254
+
255
+ // See module docstring for why these two tables and not sessions/read_files.
256
+ const TABLES = ["messages", "files"];
257
+
258
+ /**
259
+ * Turn one row (a plain object keyed by column name, as node:sqlite's
260
+ * StatementSync#iterate() returns it) into one scanned text line. No column
261
+ * allowlist — see module docstring. Two JS value shapes need converting
262
+ * before JSON.stringify can touch them without throwing or losing data:
263
+ * a BLOB-affinity column can come back as a Uint8Array (none are declared in
264
+ * this schema today, handled anyway for the same forward-compatibility
265
+ * reason cursor.js's valueToText() exists); and node:sqlite returns an
266
+ * INTEGER that doesn't fit a safe JS number as a BigInt, which
267
+ * JSON.stringify throws a TypeError on unless converted first — verified
268
+ * against node:sqlite's own documented behavior, not observed against a
269
+ * real oversized column in this schema (every INTEGER column here is a
270
+ * timestamp or a small count, in practice always safe-integer-range).
271
+ */
272
+ function rowToLine(row) {
273
+ const clean = {};
274
+ for (const [k, v] of Object.entries(row)) {
275
+ if (v instanceof Uint8Array) clean[k] = Buffer.from(v).toString("utf-8");
276
+ else if (typeof v === "bigint") clean[k] = v.toString();
277
+ else clean[k] = v;
278
+ }
279
+ return JSON.stringify(clean);
280
+ }
281
+
282
+ /**
283
+ * Read one crush.db as an array of raw text "lines" — one per row (across
284
+ * `messages` and `files`), each turned into a single JSON-stringified line
285
+ * by rowToLine(). Same status vocabulary and same iterate()-with-periodic-
286
+ * yield-and-deadline-check approach as cursor.js's readLines() for the same
287
+ * reason: node:sqlite is fully synchronous, so a wall-clock deadline can
288
+ * only be enforced between rows, not preemptively mid-row. See cursor.js's
289
+ * own docstring for the full reasoning; not re-derived here since nothing
290
+ * about it is Crush-specific.
291
+ */
292
+ async function readLines(file) {
293
+ const DB = getDatabaseSync();
294
+ if (!DB) return { lines: [], status: "failed", bytesRead: 0 };
295
+
296
+ let stat;
297
+ try { stat = fs.statSync(file); }
298
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
299
+ if (stat.size > MAX_DB_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
300
+
301
+ let db;
302
+ try {
303
+ db = new DB(file, { readOnly: true });
304
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
305
+ } catch {
306
+ // Deleted between files() and this call, a corrupt/non-SQLite file, or
307
+ // Crush itself holding a lock this readonly open can't get past within
308
+ // BUSY_TIMEOUT_MS — all genuinely "could not read this," not "read it,
309
+ // found nothing." Status "failed" keeps that distinction honest.
310
+ return { lines: [], status: "failed", bytesRead: 0 };
311
+ }
312
+
313
+ const lines = [];
314
+ let bytesRead = 0;
315
+ const deadline = Date.now() + READ_TIMEOUT_MS;
316
+ let timedOut = false;
317
+ let sawError = false;
318
+ let foundAnyTable = false;
319
+
320
+ for (const table of TABLES) {
321
+ let rows;
322
+ try {
323
+ rows = db.prepare(`SELECT * FROM ${table}`).iterate();
324
+ } catch {
325
+ // This particular table genuinely doesn't exist in this file's schema
326
+ // (a Crush version old enough to predate it, in principle) — not a
327
+ // read failure for the OTHER table, so just move on.
328
+ continue;
329
+ }
330
+ foundAnyTable = true;
331
+
332
+ let n = 0;
333
+ try {
334
+ for (const row of rows) {
335
+ const text = rowToLine(row);
336
+ lines.push(text);
337
+ bytesRead += Buffer.byteLength(text, "utf-8");
338
+ n++;
339
+ if (n % YIELD_EVERY_N_ROWS === 0) {
340
+ await new Promise((resolve) => setImmediate(resolve));
341
+ if (Date.now() > deadline) { timedOut = true; break; }
342
+ }
343
+ }
344
+ } catch {
345
+ // A row iterator can itself throw partway (e.g. a corrupted page hit
346
+ // mid-scan) — whatever WAS read before that is real content, kept the
347
+ // same way claude-code.js keeps a partial read rather than discarding it.
348
+ sawError = true;
349
+ }
350
+ if (timedOut) break;
351
+ }
352
+
353
+ try { db.close(); } catch { /* best-effort close; nothing left to do if this fails */ }
354
+
355
+ // Neither known table existed at all — opened fine as SQLite but didn't
356
+ // match the schema this source understands, a real "could not extract
357
+ // anything," not the same as "extracted zero real rows."
358
+ if (!foundAnyTable) return { lines: [], status: "failed", bytesRead: 0 };
359
+ if (sawError && lines.length === 0) return { lines: [], status: "failed", bytesRead };
360
+ if (timedOut || sawError) return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
361
+ return { lines, status: "complete", bytesRead };
362
+ }
363
+
364
+ module.exports = { id, label, available, unavailableReason, files, readLines };