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,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 };
|