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,408 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const { createInterface } = require("readline/promises");
7
+
8
+ /**
9
+ * Continue (continue.dev) — the open-source AI coding extension for VS Code
10
+ * and JetBrains. Both IDE front-ends embed the same IDE-agnostic "core"
11
+ * (github.com/continuedev/continue, package `core/`), and it is that core —
12
+ * not either IDE integration — which owns local storage, so the path below
13
+ * applies the same way regardless of which IDE the user runs Continue in.
14
+ *
15
+ * VERIFICATION STATUS (read this before trusting anything below): this is
16
+ * NOT checked against a real Continue install — no `~/.continue` directory,
17
+ * no VS Code install, and no Continue JetBrains plugin trace exist on the
18
+ * machine this adapter was built on (checked: direct path stat, `mdfind`,
19
+ * `~/.vscode/extensions`, `~/Library/Application Support/JetBrains/*`).
20
+ * Ships anyway per CONTRIBUTING.md rule 3, on the strength of reading the
21
+ * project's own real, current source directly — about as strong as
22
+ * corroboration gets short of a live install — cross-checked against two
23
+ * more independent descriptions that agree with it:
24
+ *
25
+ * 1. THE PROJECT'S OWN SOURCE CODE, read directly from
26
+ * github.com/continuedev/continue (`main` branch):
27
+ * - `core/util/paths.ts` — `getContinueGlobalPath()` resolves to
28
+ * `path.join(os.homedir(), ".continue")` (or the `CONTINUE_GLOBAL_DIR`
29
+ * env var, not honored by this adapter — see below), with NO
30
+ * per-OS branching, so this is the path on macOS, Linux, AND
31
+ * Windows alike (unlike Cursor/Trae's VS Code-inherited per-OS
32
+ * `Application Support` / `%APPDATA%` split — Continue's core is a
33
+ * separate Node process the IDE spawns, not a VS Code storage
34
+ * consumer). `getSessionsFolderPath()` /
35
+ * `getSessionFilePath(sessionId)` / `getSessionsListPath()` give
36
+ * `sessions/`, `sessions/<sessionId>.json`, and
37
+ * `sessions/sessions.json` off that root.
38
+ * - `core/util/history.ts` (`HistoryManager`) — `save()` writes one
39
+ * full `Session` object per `sessions/<uuid>.json` (pretty-printed
40
+ * `JSON.stringify(orderedSession, undefined, 2)`) and appends/
41
+ * updates a lightweight `BaseSessionMetadata` entry in the
42
+ * `sessions/sessions.json` array.
43
+ * - `core/index.d.ts` — `Session.history` is a `ChatHistoryItem[]`;
44
+ * each item's `message` carries `role` ("user" | "assistant" |
45
+ * "thinking" | "system" | "tool") and `content` (plain string OR
46
+ * an array of `{type:"text",text}` / `{type:"imageUrl",imageUrl}`
47
+ * parts), plus optional `toolCalls`, `contextItems` (full file
48
+ * contents pulled into context, `{content, name, uri, ...}`), and
49
+ * `promptLogs` (raw `{prompt, completion}` sent to/from the model)
50
+ * — i.e. real transcript content, not just metadata.
51
+ * - `core/data/log.ts` (`DataLogger.logLocalData`) — confirms local
52
+ * "dev data" logging to `dev_data/<schemaVersion>/<eventName>.jsonl`
53
+ * (via `getDevDataFilePath()` in paths.ts) runs UNCONDITIONALLY
54
+ * ("Local logs (always on for all levels)", literal comment in the
55
+ * source) at `DEFAULT_DEV_DATA_LEVEL = "all"` — i.e. on by
56
+ * default, not an opt-in telemetry path.
57
+ * - `packages/config-yaml/src/schemas/data/chatInteraction/v0.2.0.ts`
58
+ * — confirms the "all"-level `chatInteraction` event schema (the
59
+ * one written locally by default) includes `prompt` and
60
+ * `completion` as full fields, only omitted at the opt-in stricter
61
+ * "noCode" level used for some *remote* destinations.
62
+ * 2. Official docs (docs.continue.dev/development-data, via search
63
+ * excerpt — the live page itself is a JS-rendered redirect stub
64
+ * WebFetch could not follow, so this is the search engine's cached
65
+ * text of it, not a fetch of the rendered page): "By default, this
66
+ * development data is saved to .continue/dev_data on your local
67
+ * machine," independently confirming point 1's "always on" reading of
68
+ * the source.
69
+ * 3. DeepWiki's auto-generated `continuedev/continue` wiki page
70
+ * "8.7 History and Session Persistence" (deepwiki.com) — independently
71
+ * describes the same `sessions/sessions.json` +
72
+ * `sessions/<uuid>.json` split, matching the source exactly.
73
+ *
74
+ * NOT covered by this adapter, named so a future PR knows they were seen
75
+ * and deliberately left out rather than missed: `logs/core.log` and
76
+ * `logs/prompt.log` (`getLogsDirPath()`/`getPromptLogsPath()` in paths.ts)
77
+ * — free-text debug logs, not confirmed to hold full prompt/completion
78
+ * content the way `dev_data` is; `dev_data/**\/*.sqlite`
79
+ * (`getDevDataSqlitePath()`) — a separate SQLite mirror of the same dev-data
80
+ * events, redundant with the JSONL files this adapter already reads; and
81
+ * the `CONTINUE_GLOBAL_DIR` environment-variable override — honoring it
82
+ * would mean trusting an env var to redirect what gets scanned, which
83
+ * didn't seem like the right default to add silently; a relocated
84
+ * `~/.continue` installed via that var will simply read as "not installed"
85
+ * to `available()` below.
86
+ */
87
+ const ROOT = path.join(os.homedir(), ".continue");
88
+ const SESSIONS_DIR = path.join(ROOT, "sessions");
89
+ const SESSIONS_INDEX_FILE = path.join(SESSIONS_DIR, "sessions.json");
90
+ const DEV_DATA_DIR = path.join(ROOT, "dev_data");
91
+
92
+ function id() { return "continue"; }
93
+ function label() { return "Continue"; }
94
+
95
+ function available() {
96
+ // Mirrors cursor.js's choice, not claude-code.js's: like Cursor, this
97
+ // source reads from multiple sibling locations under one root
98
+ // (sessions/, dev_data/) rather than one specific content directory, so
99
+ // the umbrella root is what "installed" means here. It is not proof any
100
+ // session/event content actually exists yet — same caveat cursor.js's
101
+ // available() carries for the same reason.
102
+ try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
103
+ }
104
+
105
+ /**
106
+ * Same defensive symlink-following pattern as claude-code.js's
107
+ * isDirFollowingSymlink/isFileFollowingSymlink — see that file's docstring
108
+ * for the full reasoning. Duplicated here rather than imported, matching
109
+ * cursor.js's stated rationale: each source is meant to be a small,
110
+ * self-contained file a reviewer can audit on its own.
111
+ */
112
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
113
+ if (checkFn(dirent)) return true;
114
+ if (!dirent.isSymbolicLink()) return false;
115
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
116
+ }
117
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
118
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
119
+
120
+ /**
121
+ * Resolve one FIXED, known directory path (not discovered via a parent
122
+ * readdir, so there is no Dirent to reuse isDirFollowingSymlink against —
123
+ * same situation cursor.js's statIfPresent is in for GLOBAL_STORAGE_DB)
124
+ * into "ok" | "absent" | "broken". "absent" (path simply doesn't exist, or
125
+ * something unexpected — not a directory, not a symlink — sits there) is
126
+ * deliberately NOT reported broken, same convention as everywhere else in
127
+ * this project: broken is reserved for a path that looked like it should
128
+ * resolve to a real directory and didn't (chiefly a dangling symlink).
129
+ */
130
+ function resolveDirState(dirPath) {
131
+ let lst;
132
+ try { lst = fs.lstatSync(dirPath); } catch { return "absent"; }
133
+ if (lst.isDirectory()) return "ok";
134
+ if (lst.isSymbolicLink()) {
135
+ try { return fs.statSync(dirPath).isDirectory() ? "ok" : "broken"; }
136
+ catch { return "broken"; }
137
+ }
138
+ return "absent";
139
+ }
140
+
141
+ function* walkJsonSessionFiles() {
142
+ const state = resolveDirState(SESSIONS_DIR);
143
+ if (state === "broken") { yield { file: SESSIONS_DIR, broken: true }; return; }
144
+ if (state !== "ok") return; // no sessions/ yet — normal, not broken
145
+
146
+ let entries;
147
+ try { entries = fs.readdirSync(SESSIONS_DIR, { withFileTypes: true }); }
148
+ catch { yield { file: SESSIONS_DIR, broken: true }; return; }
149
+
150
+ for (const e of entries) {
151
+ // Covers both sessions.json (the index) and <uuid>.json (per-session
152
+ // history) — readLines() tells them apart by filename, see below.
153
+ if (!e.name.endsWith(".json")) continue;
154
+ const file = path.join(SESSIONS_DIR, e.name);
155
+ if (!e.isFile()) {
156
+ const resolved = isFileFollowingSymlink(file, e);
157
+ if (!resolved) {
158
+ if (e.isSymbolicLink()) yield { file, broken: true };
159
+ continue;
160
+ }
161
+ }
162
+ let stat;
163
+ try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
164
+ yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Yield one files()-shaped entry for a single `.jsonl` candidate, handling
170
+ * the symlink-following/broken-reporting the same way every other file
171
+ * entry in this project does. `dirent` is the Dirent from whichever
172
+ * readdirSync produced this entry.
173
+ */
174
+ function* yieldJsonlEntry(file, dirent) {
175
+ if (!dirent.isFile()) {
176
+ const resolved = isFileFollowingSymlink(file, dirent);
177
+ if (!resolved) {
178
+ if (dirent.isSymbolicLink()) yield { file, broken: true };
179
+ return;
180
+ }
181
+ }
182
+ let stat;
183
+ try { stat = fs.statSync(file); } catch { yield { file, broken: true }; return; }
184
+ yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
185
+ }
186
+
187
+ /**
188
+ * dev_data/ holds Continue's local interaction-event log — one `.jsonl`
189
+ * file per event type, written on by default (see the header docstring,
190
+ * point 1's `core/data/log.ts` note). The real, documented layout nests
191
+ * these one level down by schema version — `dev_data/<version>/<event>.jsonl`
192
+ * (`getDevDataFilePath()` in paths.ts) — but that version string
193
+ * ("0.2.0" today) is exactly the kind of value likely to drift across
194
+ * Continue releases the way cursor.js's own docstring describes already
195
+ * happening to Cursor's key names; deliberately NOT hard-coded here.
196
+ * Instead this walks two levels: any `.jsonl` sitting directly in dev_data/
197
+ * (in case a future/older layout is flatter), and one directory down inside
198
+ * whatever subdirectories dev_data/ actually contains right now, whatever
199
+ * they're named.
200
+ */
201
+ function* walkDevDataFiles() {
202
+ const state = resolveDirState(DEV_DATA_DIR);
203
+ if (state === "broken") { yield { file: DEV_DATA_DIR, broken: true }; return; }
204
+ if (state !== "ok") return; // no dev_data/ yet — normal, not broken
205
+
206
+ let topEntries;
207
+ try { topEntries = fs.readdirSync(DEV_DATA_DIR, { withFileTypes: true }); }
208
+ catch { yield { file: DEV_DATA_DIR, broken: true }; return; }
209
+
210
+ for (const e of topEntries) {
211
+ const p = path.join(DEV_DATA_DIR, e.name);
212
+
213
+ if (e.name.endsWith(".jsonl")) {
214
+ yield* yieldJsonlEntry(p, e);
215
+ continue;
216
+ }
217
+
218
+ if (!isDirFollowingSymlink(p, e)) {
219
+ if (e.isSymbolicLink()) yield { file: p, broken: true };
220
+ continue; // some other stray entry — out of scope, not broken
221
+ }
222
+
223
+ let innerEntries;
224
+ try { innerEntries = fs.readdirSync(p, { withFileTypes: true }); }
225
+ catch { yield { file: p, broken: true }; continue; }
226
+
227
+ for (const ie of innerEntries) {
228
+ if (!ie.name.endsWith(".jsonl")) continue;
229
+ yield* yieldJsonlEntry(path.join(p, ie.name), ie);
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Yield { file, mtimeMs, sizeBytes, broken } for every candidate file this
236
+ * source knows about: session history JSON (sessions/) and default-on
237
+ * interaction-event JSONL (dev_data/). See CONTRIBUTING.md and the header
238
+ * docstring above for what's deliberately excluded.
239
+ */
240
+ function* files() {
241
+ yield* walkJsonSessionFiles();
242
+ yield* walkDevDataFiles();
243
+ }
244
+
245
+ // dev_data/*.jsonl is a genuinely line-delimited, append-only log (grows
246
+ // with every autocomplete/chat/tool-use event Continue records), so it gets
247
+ // the same bounds claude-code.js uses for its own append-only transcripts —
248
+ // by analogy, not because a real multi-GB dev_data file was observed; no
249
+ // install existed to observe one against (see header docstring).
250
+ const MAX_JSONL_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
251
+ // sessions/*.json is one whole JSON document per file (a single session's
252
+ // history, or the sessions.json index) — not append-only in the same way,
253
+ // and has no real large example to size this against either. Generous
254
+ // backstop against a corrupted/pathological file, same admitted status as
255
+ // cursor.js's own MAX_DB_BYTES.
256
+ const MAX_JSON_DOC_BYTES = 512 * 1024 * 1024;
257
+ const READ_TIMEOUT_MS = 60_000;
258
+
259
+ /**
260
+ * Read a genuinely line-delimited `.jsonl` file (dev_data/) exactly the way
261
+ * claude-code.js reads its JSONL transcripts — see that file's docstring
262
+ * for the full reasoning (streaming over readFileSync+split because a real
263
+ * 818MB transcript overflowed V8's single-string limit; the destroy-on-timeout
264
+ * timer because nothing in Node's stream/readline stack times out on its
265
+ * own). Reused verbatim here rather than imported, per this project's
266
+ * one-small-self-contained-file-per-source convention.
267
+ */
268
+ async function readJsonlFile(file) {
269
+ let stat;
270
+ try { stat = fs.statSync(file); }
271
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
272
+ if (stat.size > MAX_JSONL_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
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
279
+
280
+ try {
281
+ for await (const line of rl) {
282
+ lines.push(line);
283
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
284
+ }
285
+ return { lines, status: "complete", bytesRead };
286
+ } catch {
287
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
288
+ } finally {
289
+ clearTimeout(timer);
290
+ rl.close();
291
+ stream.destroy();
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Turn a parsed sessions.json index (expected: BaseSessionMetadata[]) into
297
+ * one scanned "line" per entry. Falls back to a single line for the whole
298
+ * parsed value if it isn't the expected array shape (e.g. an older/newer
299
+ * index format) — still scanned, just not decomposed per-entry.
300
+ */
301
+ function linesFromSessionsIndex(parsed) {
302
+ if (Array.isArray(parsed)) return parsed.map((entry) => JSON.stringify(entry));
303
+ return [JSON.stringify(parsed)];
304
+ }
305
+
306
+ /**
307
+ * Turn a parsed individual session document (expected: Session, per
308
+ * core/index.d.ts — sessionId/title/workspaceDirectory/history[]/...) into
309
+ * one line per ChatHistoryItem in `history`, plus one line for everything
310
+ * else in the document (title, workspaceDirectory, mode, chatModelTitle,
311
+ * usage) so a secret sitting somewhere outside `history` — implausible, but
312
+ * this project's whole point is not assuming — still gets scanned. Falls
313
+ * back to a single line for the whole parsed value if `history` isn't an
314
+ * array (older/corrupted format).
315
+ */
316
+ function linesFromSessionDocument(parsed) {
317
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.history)) {
318
+ return [JSON.stringify(parsed)];
319
+ }
320
+ const { history, ...rest } = parsed;
321
+ const lines = [JSON.stringify(rest)];
322
+ for (const item of history) lines.push(JSON.stringify(item));
323
+ return lines;
324
+ }
325
+
326
+ /**
327
+ * Read one whole JSON document file (sessions/sessions.json or
328
+ * sessions/<uuid>.json) and flatten it into scanned "lines" — see
329
+ * linesFromSessionsIndex/linesFromSessionDocument above for how, chosen by
330
+ * filename the same way files() constructs these two kinds of paths.
331
+ *
332
+ * Unlike readJsonlFile above, this can't match patterns against the file as
333
+ * it streams in — the file is one JSON document, not one record per line,
334
+ * so it must be fully assembled before JSON.parse can run at all. It still
335
+ * streams the raw bytes in (rather than fs.readFileSync) so the same
336
+ * destroy-on-timeout protection applies to a file whose read might hang
337
+ * (e.g. a symlink retargeted onto a FIFO with no writer, same scenario
338
+ * claude-code.js's docstring describes) — the read is bounded even though
339
+ * the eventual JSON.parse of the fully-assembled text is not.
340
+ *
341
+ * If the assembled text fails to parse as JSON (corrupted file, or a
342
+ * genuinely different/older format than core/index.d.ts describes), the
343
+ * raw text is scanned as a single line rather than discarded — every byte
344
+ * that was actually read off disk is still scanned, just not decomposed
345
+ * into per-message lines. Status is "complete" in that case: reading the
346
+ * file did succeed start to finish, it just isn't the JSON shape assumed.
347
+ */
348
+ async function readJsonDocumentFile(file) {
349
+ let stat;
350
+ try { stat = fs.statSync(file); }
351
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
352
+ if (stat.size > MAX_JSON_DOC_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
353
+
354
+ let text = "";
355
+ let tooLarge = false;
356
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
357
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
358
+
359
+ // Resolves exactly once, from whichever of 'end'/'error' fires first —
360
+ // 'error' also covers the timeout/size-cap destroy() calls above, since
361
+ // destroying a stream mid-read emits 'error', not a silent 'close'.
362
+ const readCleanly = await new Promise((resolve) => {
363
+ stream.on("data", (chunk) => {
364
+ text += chunk;
365
+ if (!tooLarge && Buffer.byteLength(text, "utf-8") > MAX_JSON_DOC_BYTES) {
366
+ tooLarge = true;
367
+ stream.destroy();
368
+ }
369
+ });
370
+ stream.once("end", () => resolve(true));
371
+ stream.once("error", () => resolve(false));
372
+ });
373
+ clearTimeout(timer);
374
+
375
+ if (tooLarge) return { lines: [], status: "too-large", bytesRead: 0 };
376
+
377
+ const bytesRead = Buffer.byteLength(text, "utf-8");
378
+ if (!readCleanly && bytesRead === 0) return { lines: [], status: "failed", bytesRead: 0 };
379
+
380
+ const isIndex = path.basename(file) === "sessions.json" && path.dirname(file) === SESSIONS_DIR;
381
+ // A read that didn't finish cleanly (timed out, or errored partway) still
382
+ // handed us real bytes off disk — scan them rather than discard them,
383
+ // same principle as claude-code.js's own partial-read handling, just
384
+ // applied to a whole-document read instead of a line-by-line one.
385
+ const status = readCleanly ? "complete" : "partial";
386
+
387
+ if (text.length === 0) return { lines: [], status, bytesRead };
388
+
389
+ try {
390
+ const parsed = JSON.parse(text);
391
+ const lines = isIndex ? linesFromSessionsIndex(parsed) : linesFromSessionDocument(parsed);
392
+ return { lines, status, bytesRead };
393
+ } catch {
394
+ // Not valid JSON — corrupted, truncated by a timeout/error partway
395
+ // through, or a genuinely different format than core/index.d.ts
396
+ // describes. Whatever text WAS read is real content and may contain a
397
+ // real secret; scan it as one raw line rather than discarding it just
398
+ // because it didn't parse.
399
+ return { lines: [text], status, bytesRead };
400
+ }
401
+ }
402
+
403
+ async function readLines(file) {
404
+ if (file.endsWith(".jsonl")) return readJsonlFile(file);
405
+ return readJsonDocumentFile(file);
406
+ }
407
+
408
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,272 @@
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 Chat — the VS Code extension/panel (github.copilot-chat),
10
+ * NOT the standalone `copilot`/`gh copilot` CLI (see copilot-cli.js for that,
11
+ * a genuinely different product with a genuinely different storage format).
12
+ *
13
+ * VERIFICATION STATUS (read this before trusting anything below):
14
+ * multi-source-corroborated-but-UNVERIFIED against a real install. VS Code
15
+ * itself is not installed on the machine this adapter was built on (checked:
16
+ * no /Applications/*Code*.app, no ~/Library/Application Support/Code, no
17
+ * ~/Library/Application Support/Code - Insiders, no `code`/`code-insiders` on
18
+ * PATH). What IS unusually strong here, short of a real install: the storage
19
+ * mechanism and exact on-disk filenames below were read directly out of VS
20
+ * Code's own current shipped source (microsoft/vscode, fetched verbatim via
21
+ * `gh api repos/microsoft/vscode/contents/...` on 2026-09-02), not inferred
22
+ * from a blog post — see the file-by-file citations inline below. Treat
23
+ * findings from this source with the same caution as windsurf.js/void.js
24
+ * until someone with VS Code + Copilot Chat installed confirms it against
25
+ * real data (see CONTRIBUTING.md).
26
+ *
27
+ * WHERE THIS LIVES AND WHY (VS Code core chat storage, not Copilot-specific):
28
+ * Chat session persistence is implemented in VS Code CORE
29
+ * (`src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts`), shared
30
+ * by every chat participant an installed extension might register — it is
31
+ * not a Copilot-owned table or folder the way Cursor's `cursorDiskKV` is
32
+ * Cursor-owned (see cursor.js). Copilot Chat is simply the default,
33
+ * overwhelmingly dominant participant that actually writes real content
34
+ * there for the vast majority of installs, which is exactly what this
35
+ * cluster's brief means by "may store history in VS Code's workspaceStorage."
36
+ *
37
+ * Read directly from `chatSessionStore.ts` (constructor and
38
+ * handleWorkspaceTransition(), microsoft/vscode@780ea331b2861816fe6bb8215d81
39
+ * 2933c81df83b, the file's own most recent commit as of this research,
40
+ * 2026-08-06, "Harden chat import storage paths"):
41
+ * - Normal case (a folder/workspace is open): sessions live under
42
+ * <workspaceStorageHome>/<workspaceId>/chatSessions/
43
+ * where <workspaceId> is VS Code's own per-workspace hash directory name
44
+ * (workspaceStorage/<hash>/) — the same directory family cursor.js and
45
+ * void.js already walk in this project for their own state.vscdb files.
46
+ * - Empty window (no folder open): sessions live under the DEFAULT
47
+ * profile's globalStorage instead:
48
+ * <globalStorageHome>/emptyWindowChatSessions/
49
+ * - Legacy fallback, empty-window sessions from before this path existed:
50
+ * <workspaceStorageHome>/no-workspace/chatSessions/
51
+ * (read as a fallback only when a session isn't found at the current
52
+ * location; this adapter scans it unconditionally rather than trying to
53
+ * replicate that fallback logic, since "no-workspace" is simply one more
54
+ * literal-named entry under workspaceStorage/ that the generic walk
55
+ * below already visits like any other hash directory).
56
+ * - Sessions carried across a workspace-identity change (e.g. "Save
57
+ * Workspace As"):
58
+ * <globalStorageHome>/transferredChatSessions/
59
+ *
60
+ * Filenames, read directly from `getChatSessionStorageResource()` in
61
+ * `chatUri.ts` (same commit) and its two call sites in `chatSessionStore.ts`:
62
+ * each session is `<sessionId>.json` (a "flat" full-snapshot write) and/or,
63
+ * when `chat.useLogSessionStorage` is enabled (VS Code's own default is
64
+ * true), `<sessionId>.jsonl` (an append-only operation log — VS Code prefers
65
+ * reading this one when both exist: see `readSessionFromLocation()`, which
66
+ * tries the `.jsonl` location first and falls back to `.json`). Real user
67
+ * reports independently corroborate this exact "workspaceStorage/<hash>/
68
+ * chatSessions/*.json(.jsonl)" shape and the JSONL-vs-JSON split before this
69
+ * adapter's own source-reading confirmed it directly:
70
+ * - microsoft/vscode issue #285059 ("chat sessions remain in old
71
+ * workspaceStorage hash") and #291897 ("missing chatSessions JSON in new
72
+ * workspaceStorage") — real user bug reports naming the exact directory.
73
+ * - microsoft/vscode issue #308730 ("malformed chatSessions JSONL") — a
74
+ * real user hitting a parse error in the `.jsonl` form specifically.
75
+ * - dev.to/5a9awneh's "VS Code is silently losing your Copilot chat
76
+ * history" write-up, independently describing the same
77
+ * `workspaceStorage/<hash>/chatSessions/` JSONL layout.
78
+ * Base "User" directory per OS (official VS Code docs,
79
+ * code.visualstudio.com/docs/getstarted/settings — "%APPDATA%\Code\User" on
80
+ * Windows, "$HOME/Library/Application Support/Code/User" on macOS; Linux
81
+ * follows the same XDG-config convention cursor.js documents): this adapter
82
+ * checks both standard VS Code ("Code") and VS Code Insiders ("Code -
83
+ * Insiders"), the same narrower-than-exhaustive scope cline.js already
84
+ * documents and justifies for itself in this project — VSCodium, other VS
85
+ * Code forks, and the separate ~/.vscode-server tree used by remote-SSH
86
+ * sessions are deliberately NOT covered here.
87
+ *
88
+ * Explicitly out of scope, named rather than silently skipped:
89
+ * - Non-default VS Code profiles. A custom profile's globalStorage lives
90
+ * under `User/profiles/<profileId>/...` instead of `User/globalStorage`
91
+ * directly (per `IUserDataProfilesService.defaultProfile` vs. other
92
+ * profiles in the source read above) — only the default profile is
93
+ * walked here.
94
+ * - The session INDEX/title list. `chatSessionStore.ts` also persists a
95
+ * lightweight per-session index (session id -> `title`, via storage key
96
+ * `chat.ChatSessionStore.index`) through VS Code's generic
97
+ * IStorageService, which resolves to rows in the shared, per-profile
98
+ * `globalStorage/state.vscdb` `ItemTable` — the same file cursor.js and
99
+ * void.js already parse for their own products. That index is METADATA
100
+ * ONLY (an auto-generated title string per session, per
101
+ * `IChatSessionEntryMetadata` in chatSessionStore.ts) — actual message
102
+ * content, tool-call arguments, and anything pasted into the chat live
103
+ * exclusively in the chatSessions/*.json(.jsonl) files this adapter does
104
+ * read. Skipping the title index is the same call cline.js already makes
105
+ * for its own state.vscdb-backed task-title list, for the same reason.
106
+ */
107
+ function vscodeUserDirs() {
108
+ const home = os.homedir();
109
+ const variants = ["Code", "Code - Insiders"];
110
+ if (process.platform === "darwin") {
111
+ return variants.map((v) => path.join(home, "Library", "Application Support", v, "User"));
112
+ }
113
+ if (process.platform === "win32") {
114
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
115
+ return variants.map((v) => path.join(appData, v, "User"));
116
+ }
117
+ // Linux and other XDG-following unix platforms.
118
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
119
+ return variants.map((v) => path.join(configHome, v, "User"));
120
+ }
121
+
122
+ // Bounds for readLines() — same rationale and same values as claude-code.js
123
+ // and cline.js. Not backed by a real Copilot Chat transcript this tool was
124
+ // tested against (no VS Code install to test with) — see the
125
+ // verification-status note above.
126
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
127
+ const READ_TIMEOUT_MS = 60_000;
128
+
129
+ function id() { return "copilot-chat"; }
130
+ function label() { return "GitHub Copilot Chat"; }
131
+
132
+ function available() {
133
+ return vscodeUserDirs().some((dir) => {
134
+ try { return fs.statSync(dir).isDirectory(); } catch { return false; }
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Same defensive symlink-following pattern as claude-code.js's
140
+ * isDirFollowingSymlink/isFileFollowingSymlink — see that file's docstring
141
+ * for the full reasoning. Duplicated rather than imported, matching this
142
+ * project's "small, self-contained file" convention (see cursor.js's own
143
+ * note on this).
144
+ */
145
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
146
+ if (checkFn(dirent)) return true;
147
+ if (!dirent.isSymbolicLink()) return false;
148
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
149
+ }
150
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
151
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
152
+
153
+ /**
154
+ * Yield { file, mtimeMs, sizeBytes, broken } for every `*.json`/`*.jsonl`
155
+ * session file directly inside one chatSessions-shaped directory (flat, not
156
+ * recursive — chatSessionStore.ts writes sessions as immediate children of
157
+ * its storage root, confirmed via getChatSessionStorageResource()'s own
158
+ * dirname-equality check).
159
+ *
160
+ * A directory that simply doesn't exist yields nothing — normal, not broken:
161
+ * most workspaceStorage/<hash> entries are for workspaces that never opened
162
+ * the Chat view, and empty/transferred/legacy chat-session directories are
163
+ * only ever created on first use. Only an entry that looked like it should
164
+ * resolve and didn't (chiefly a dangling symlink) is reported broken, same
165
+ * convention as every other source in this project.
166
+ */
167
+ function* walkSessionFilesDir(dir) {
168
+ let entries;
169
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
170
+ catch { return; }
171
+
172
+ for (const e of entries) {
173
+ if (!(e.name.endsWith(".json") || e.name.endsWith(".jsonl"))) continue;
174
+ const file = path.join(dir, e.name);
175
+ if (!isFileFollowingSymlink(file, e)) {
176
+ if (e.isSymbolicLink()) yield { file, broken: true };
177
+ continue;
178
+ }
179
+ let stat;
180
+ try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
181
+ yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Walk every entry directly under workspaceStorage/ (each one is either a
187
+ * per-workspace hash directory or the literal "no-workspace" legacy
188
+ * directory — both are just directories containing an optional
189
+ * chatSessions/ subdirectory, so no special-casing is needed) and yield
190
+ * whatever session files are inside each one's chatSessions/.
191
+ */
192
+ function* walkWorkspaceStorage(workspaceStorageDir) {
193
+ let entries;
194
+ try { entries = fs.readdirSync(workspaceStorageDir, { withFileTypes: true }); }
195
+ catch { return; } // no workspaceStorage at all for this VS Code variant/profile — normal
196
+
197
+ for (const e of entries) {
198
+ const entryDir = path.join(workspaceStorageDir, e.name);
199
+ if (!isDirFollowingSymlink(entryDir, e)) {
200
+ if (e.isSymbolicLink()) yield { file: entryDir, broken: true };
201
+ continue; // a stray non-directory entry under workspaceStorage/ is out of scope, not broken
202
+ }
203
+ yield* walkSessionFilesDir(path.join(entryDir, "chatSessions"));
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Yield { file, mtimeMs, sizeBytes, broken } for every Copilot/VS-Code-chat
209
+ * session file this adapter knows how to find, across every candidate VS
210
+ * Code User dir (standard + Insiders): every workspaceStorage/<hash-or-
211
+ * "no-workspace">/chatSessions/*.json(.jsonl), plus the default profile's
212
+ * globalStorage/emptyWindowChatSessions/ and globalStorage/
213
+ * transferredChatSessions/.
214
+ */
215
+ function* files() {
216
+ for (const userDir of vscodeUserDirs()) {
217
+ yield* walkWorkspaceStorage(path.join(userDir, "workspaceStorage"));
218
+
219
+ const globalStorageDir = path.join(userDir, "globalStorage");
220
+ yield* walkSessionFilesDir(path.join(globalStorageDir, "emptyWindowChatSessions"));
221
+ yield* walkSessionFilesDir(path.join(globalStorageDir, "transferredChatSessions"));
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Read one chatSessions file as an array of raw text lines.
227
+ *
228
+ * Both known shapes are ordinary UTF-8 text — a `.jsonl` operation log is
229
+ * one JSON record per line by construction, and a `.json` flat snapshot is
230
+ * still real text (pretty-printed or not) rather than a binary format — so
231
+ * the same streamed readline/promises approach claude-code.js and cline.js
232
+ * use applies unchanged: no whole-file-as-one-string V8 length ceiling, and
233
+ * a partial read still returns whatever lines WERE read rather than
234
+ * discarding real content. A minified single-line `.json` file just becomes
235
+ * one long "line," bounded the same way by MAX_BYTES.
236
+ *
237
+ * Status vocabulary matches every other source in this project: "complete",
238
+ * "partial", "too-large", "failed".
239
+ */
240
+ async function readLines(file) {
241
+ let stat;
242
+ try { stat = fs.statSync(file); }
243
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
244
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
245
+
246
+ const lines = [];
247
+ let bytesRead = 0;
248
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
249
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
250
+
251
+ // Same rationale as claude-code.js: no natural timeout exists anywhere in
252
+ // Node's stream/readline stack, and a retargeted symlink can make the
253
+ // underlying open() block forever with no event ever firing. Destroying
254
+ // the stream is what actually unblocks that.
255
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
256
+
257
+ try {
258
+ for await (const line of rl) {
259
+ lines.push(line);
260
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
261
+ }
262
+ return { lines, status: "complete", bytesRead };
263
+ } catch {
264
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
265
+ } finally {
266
+ clearTimeout(timer);
267
+ rl.close();
268
+ stream.destroy();
269
+ }
270
+ }
271
+
272
+ module.exports = { id, label, available, files, readLines };