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,395 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+
7
+ /**
8
+ * Warp terminal's local session/block/agent database.
9
+ *
10
+ * VERIFICATION STATUS (read this before trusting anything below): the per-OS
11
+ * paths below are quoted VERBATIM out of Warp's own official documentation —
12
+ * fetched as raw HTML directly (not through an AI-summarized fetch, after an
13
+ * earlier summarized pass turned out to blur two different sections of the
14
+ * same page together) and grepped for the literal strings, so what follows
15
+ * is transcription, not paraphrase. This has NOT been checked against a real
16
+ * Warp install — Warp is not installed on the machine this adapter was built
17
+ * on (checked: no Warp.app under /Applications, no matching directory under
18
+ * `~/Library/Application Support` or `~/Library/Group Containers`, not
19
+ * findable via `mdfind`). If you have Warp installed, the most useful thing
20
+ * you can do is run `residoo scan` and confirm `sourcesScanned`/`filesScanned`
21
+ * look right for what you know is actually on disk, then report back either
22
+ * way.
23
+ *
24
+ * THE FILE AND WHERE IT LIVES. Warp's own docs, "Session Restoration"
25
+ * (https://docs.warp.dev/terminal/sessions/session-restoration/,
26
+ * "#session-restoration-database" — fetched Sept 2026), give the exact path
27
+ * per OS as a copy-pasteable `sqlite3 "<path>"` command a user can run to
28
+ * inspect the file directly:
29
+ *
30
+ * macOS: $HOME/Library/Group Containers/2BBY89MBSN.dev.warp/Library/
31
+ * Application Support/dev.warp.Warp-Stable/warp.sqlite
32
+ * Windows: $env:LOCALAPPDATA\warp\Warp\data\warp.sqlite
33
+ * Linux: ${XDG_STATE_HOME:-$HOME/.local/state}/warp-terminal/warp.sqlite
34
+ *
35
+ * The same page states plainly what this file is for and why it belongs in
36
+ * a secret scanner: "Warp saves the data from your previous session's
37
+ * windows, tabs, and panes to a SQLite database on your computer" — AND, in
38
+ * its own section on clearing it: "you may want to prevent a sensitive
39
+ * Block from being saved on your computer, or you may want to clear blocks
40
+ * from a machine entirely" (deleting it is described as destructive because
41
+ * it "will delete any sessions AND BLOCK HISTORY"). That is Warp's own
42
+ * documentation confirming this file holds real block (command + output)
43
+ * content, not just window geometry — exactly the "AI-block history" this
44
+ * source was scoped to cover.
45
+ *
46
+ * Warp's docs separately confirm a Preview release channel uses parallel,
47
+ * differently-named directories specifically so it never collides with
48
+ * Stable's data — "Logging out & uninstalling"
49
+ * (.../logging-out-and-uninstalling/) gives, verbatim:
50
+ * macOS Preview: .../dev.warp.Warp-Preview (same Group Containers root)
51
+ * Windows Preview: %LOCALAPPDATA%\warp\WarpPreview\ (logs confirmed at
52
+ * ...\WarpPreview\data\logs\warp_preview.log*, i.e. the
53
+ * same `...\data\` layout as Stable, one level up)
54
+ * Linux Preview: "Append `-preview` to each `warp-terminal` directory"
55
+ * (file-locations page, verbatim) → warp-terminal-preview
56
+ * None of those three quotes spells out "warp.sqlite" specifically for
57
+ * Preview (only Preview's log filename is spelled out that precisely) — the
58
+ * Preview `warp.sqlite` path below is filled in by direct structural analogy
59
+ * to Stable's confirmed layout, not independently quoted. Flagged here
60
+ * rather than silently assumed.
61
+ *
62
+ * SCHEMA. Warp's docs do not publish a schema. Table names are corroborated
63
+ * by two independent sources that had to actually query a real file to know
64
+ * them:
65
+ * - github.com/8agana/warp-sqlite-mcp — a maintained MCP server built
66
+ * specifically to query this database (confirmed against its source,
67
+ * not just its README: it takes a `table` name as a runtime parameter
68
+ * for generic `sqlite_select`/`sqlite_insert`/etc., so it doesn't hardcode
69
+ * a schema in code either — but its README, written by someone with a
70
+ * real install, names `ai_queries`, `agent_conversations`, `notebooks`,
71
+ * `active_mcp_servers`, and `commands` as tables it has seen, and gives
72
+ * a macOS path matching the official docs above character-for-character).
73
+ * - github.com/warpdotdev/Warp issue #7760 ("Agent Chat Sessions Not
74
+ * Restored on Restart") — a real user's own direct query against their
75
+ * own live Linux install (`~/.local/state/warp-terminal/warp.sqlite`,
76
+ * matching the official path above), showing an `agent_conversations`
77
+ * table with columns `id`, `conversation_id`, `last_modified_at`, and
78
+ * `conversation_data` (a blob holding the actual conversation content).
79
+ *
80
+ * That is a real, named, partial table list, not a complete one — and this
81
+ * source deliberately does NOT hardcode it. Instead, readLines() below
82
+ * queries `sqlite_master` at scan time to discover whatever tables actually
83
+ * exist in the specific file being read, and reads all of them (skipping
84
+ * only SQLite's own internal `sqlite_%` tables). Three reasons, stated
85
+ * plainly:
86
+ * 1. No source consulted claims to be a complete table list, and Warp's
87
+ * own schema is independently known to have changed shape over time —
88
+ * issue #7760 is itself a report of a schema/behavior regression.
89
+ * A hardcoded allowlist here would silently miss whatever the true
90
+ * block/command-history table is called if it doesn't match one of the
91
+ * five names above, which is precisely the "schema drifted, scan
92
+ * quietly covered less than it claimed" failure this project exists to
93
+ * avoid (see CONTRIBUTING.md rule 5, and the agentsweep incident
94
+ * described in this source's task brief).
95
+ * 2. A table this source doesn't recognize by name (window position
96
+ * integers, MCP server registrations, etc.) costs nothing extra to
97
+ * scan and cannot itself produce a false secret match — it just adds a
98
+ * few harmless, pattern-unmatched lines. There is no accuracy trade to
99
+ * make by including it.
100
+ * 3. This is the same principle cursor.js already applies one level down
101
+ * (reading every row's value without a column-name allowlist, for the
102
+ * same "the exact set of content-bearing keys has already changed
103
+ * across versions" reason) — applied here one level up, to table names
104
+ * instead of column names, because Warp's schema is a whole database of
105
+ * tables rather than Cursor's fixed two.
106
+ *
107
+ * Nothing in Warp's documented pragmas or in either community source
108
+ * suggests this file is encrypted (session-restoration docs literally tell
109
+ * users to open it with a bare `sqlite3 "<path>"`, no key or password) — a
110
+ * password-protected file would make that instruction meaningless.
111
+ */
112
+ function warpStateDirs() {
113
+ const home = os.homedir();
114
+
115
+ if (process.platform === "darwin") {
116
+ const base = path.join(
117
+ home, "Library", "Group Containers", "2BBY89MBSN.dev.warp",
118
+ "Library", "Application Support"
119
+ );
120
+ return [
121
+ { channel: "stable", dir: path.join(base, "dev.warp.Warp-Stable") },
122
+ { channel: "preview", dir: path.join(base, "dev.warp.Warp-Preview") },
123
+ ];
124
+ }
125
+
126
+ if (process.platform === "win32") {
127
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
128
+ return [
129
+ { channel: "stable", dir: path.join(localAppData, "warp", "Warp", "data") },
130
+ { channel: "preview", dir: path.join(localAppData, "warp", "WarpPreview", "data") },
131
+ ];
132
+ }
133
+
134
+ // Linux and other XDG-following unix platforms.
135
+ const xdgStateHome = process.env.XDG_STATE_HOME || path.join(home, ".local", "state");
136
+ return [
137
+ { channel: "stable", dir: path.join(xdgStateHome, "warp-terminal") },
138
+ { channel: "preview", dir: path.join(xdgStateHome, "warp-terminal-preview") },
139
+ ];
140
+ }
141
+
142
+ function candidateDbPaths() {
143
+ return warpStateDirs().map(({ dir }) => path.join(dir, "warp.sqlite"));
144
+ }
145
+
146
+ /**
147
+ * Same lazy-require, feature-detected node:sqlite pattern as cursor.js and
148
+ * crush.js — see cursor.js's docstring for the full reasoning (an eager
149
+ * top-level require would print Node's ExperimentalWarning on every
150
+ * `residoo scan` for every user, even the majority who have never touched
151
+ * Warp). Duplicated rather than shared: each source here is meant to be a
152
+ * small, self-contained file a reviewer can audit on its own
153
+ * (CONTRIBUTING.md).
154
+ */
155
+ const NODE_SQLITE_REQUIREMENT = "needs Node.js 22.5+ (node:sqlite not present in this runtime)";
156
+ let sqliteRequireAttempted = false;
157
+ let DatabaseSync = null;
158
+
159
+ function getDatabaseSync() {
160
+ if (!sqliteRequireAttempted) {
161
+ sqliteRequireAttempted = true;
162
+ try { ({ DatabaseSync } = require("node:sqlite")); }
163
+ catch { DatabaseSync = null; }
164
+ }
165
+ return DatabaseSync;
166
+ }
167
+
168
+ function id() { return "warp"; }
169
+ function label() { return "Warp"; }
170
+
171
+ /**
172
+ * Deliberately checks the CONTAINING DIRECTORY, not the warp.sqlite file
173
+ * itself, and deliberately does not follow into "does the file resolve" —
174
+ * same reasoning as claude-code.js's/cursor.js's available(), which gate on
175
+ * a root directory rather than a specific leaf file. This matters concretely
176
+ * here: statSync on a *file* path follows symlinks and throws for a
177
+ * dangling one, which would make available() report false for exactly the
178
+ * broken-symlink case files() exists to surface — silently un-gating the
179
+ * whole source (per cursor.js's own docstring, available() is what decides
180
+ * whether files()/readLines() get called at all) right when there's a
181
+ * broken entry most worth reporting. Checking the directory instead avoids
182
+ * that trap: a directory being present is a stable signal that Warp (this
183
+ * channel) is installed, independent of whatever state the db file itself
184
+ * is in.
185
+ */
186
+ function anyWarpDirExists() {
187
+ for (const { dir } of warpStateDirs()) {
188
+ try { if (fs.statSync(dir).isDirectory()) return true; } catch { /* not this one */ }
189
+ }
190
+ return false;
191
+ }
192
+
193
+ function available() {
194
+ // Cheap fs checks first, same short-circuit reasoning as cursor.js's and
195
+ // crush.js's available(): the common case is Warp simply isn't installed,
196
+ // and that must not cost requiring node:sqlite.
197
+ return anyWarpDirExists() && Boolean(getDatabaseSync());
198
+ }
199
+
200
+ /**
201
+ * Same additive, optional export as cursor.js's/crush.js's
202
+ * unavailableReason() — see cursor.js's docstring. Distinguishes "Warp
203
+ * isn't here" (say nothing) from "Warp is here but this Node runtime can't
204
+ * read its database" (say so).
205
+ */
206
+ function unavailableReason() {
207
+ if (!anyWarpDirExists()) return null;
208
+ if (getDatabaseSync()) return null;
209
+ return `Warp detected but not scanned — ${NODE_SQLITE_REQUIREMENT}`;
210
+ }
211
+
212
+ /**
213
+ * Resolve one candidate `warp.sqlite` path into zero or one files() entries.
214
+ * Identical in shape and reasoning to cursor.js's and crush.js's
215
+ * statIfPresent(): these paths are constructed (fixed per-OS/per-channel
216
+ * layout), not discovered by listing a directory, so lstat is used directly.
217
+ * A channel that was simply never installed (Preview, most commonly) yields
218
+ * nothing and is NOT broken; a dangling symlink IS.
219
+ */
220
+ function* statIfPresent(dbPath) {
221
+ let lst;
222
+ try { lst = fs.lstatSync(dbPath); }
223
+ catch { return; }
224
+
225
+ if (lst.isSymbolicLink()) {
226
+ try {
227
+ const st = fs.statSync(dbPath); // follow the link
228
+ if (!st.isFile()) { yield { file: dbPath, broken: true }; return; }
229
+ yield { file: dbPath, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
230
+ } catch {
231
+ yield { file: dbPath, broken: true }; // dangling symlink
232
+ }
233
+ return;
234
+ }
235
+
236
+ if (!lst.isFile()) return; // something unexpected sits at this path — out of scope, not broken
237
+ yield { file: dbPath, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
238
+ }
239
+
240
+ /**
241
+ * Yield { file, mtimeMs, sizeBytes, broken } for warp.sqlite under every
242
+ * release channel (Stable, Preview) this platform's layout defines. Purely
243
+ * a filesystem walk + stat — never opens the database, so it works (and can
244
+ * be exercised in tests) even in a Node runtime where node:sqlite isn't
245
+ * available.
246
+ */
247
+ function* files() {
248
+ for (const dbPath of candidateDbPaths()) {
249
+ yield* statIfPresent(dbPath);
250
+ }
251
+ }
252
+
253
+ // A warp.sqlite this large has not been observed anywhere in this source's
254
+ // research — no real install was available to produce or measure one.
255
+ // Picked deliberately larger than cursor.js's 512MB backstop: unlike
256
+ // Cursor's editor-state file, this one accumulates terminal BLOCK content
257
+ // (commands + their full output) continuously for as long as Session
258
+ // Restoration stays enabled, which is a plausibly much larger growth curve
259
+ // over months/years of daily use — but this is reasoning from the file's
260
+ // documented purpose, not a measurement. Same honest caveat as this
261
+ // project's other unverified sources' size backstops.
262
+ const MAX_DB_BYTES = 1024 * 1024 * 1024; // 1GB
263
+ const READ_TIMEOUT_MS = 60_000;
264
+ const BUSY_TIMEOUT_MS = 5_000; // bound how long a read waits on a lock Warp itself may be holding
265
+ const YIELD_EVERY_N_ROWS = 500;
266
+
267
+ function quoteIdent(name) {
268
+ return '"' + name.replace(/"/g, '""') + '"';
269
+ }
270
+
271
+ /**
272
+ * List every real, user-facing table in this database file, discovered from
273
+ * `sqlite_master` rather than assumed — see the module docstring for why
274
+ * this source deliberately does not hardcode a table allowlist. SQLite's
275
+ * own internal bookkeeping tables (`sqlite_sequence` and similar, always
276
+ * prefixed `sqlite_`) are excluded; everything else Warp itself created is
277
+ * read.
278
+ */
279
+ function discoverTableNames(db) {
280
+ try {
281
+ const rows = db.prepare(
282
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'"
283
+ ).all();
284
+ return rows.map((r) => r.name).filter((n) => typeof n === "string" && n.length > 0);
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Turn one row (a plain object keyed by column name, as node:sqlite's
292
+ * StatementSync#iterate() returns it) into one scanned text line. No column
293
+ * allowlist, same reasoning as cursor.js's valueToText() and crush.js's
294
+ * rowToLine(): a BLOB-affinity column can come back as a Uint8Array
295
+ * (confirmed against this project's own node:sqlite by cursor.js's
296
+ * research — not a Buffer, despite Node's docs describing BLOB columns
297
+ * loosely); an INTEGER too large for a safe JS number comes back as a
298
+ * BigInt, which JSON.stringify throws on unless converted first. Both are
299
+ * converted to plain strings before stringifying so this can never throw on
300
+ * a row shape it didn't anticipate.
301
+ */
302
+ function rowToLine(row) {
303
+ const clean = {};
304
+ for (const [k, v] of Object.entries(row)) {
305
+ if (v instanceof Uint8Array) clean[k] = Buffer.from(v).toString("utf-8");
306
+ else if (typeof v === "bigint") clean[k] = v.toString();
307
+ else clean[k] = v;
308
+ }
309
+ return JSON.stringify(clean);
310
+ }
311
+
312
+ /**
313
+ * Read one warp.sqlite as an array of raw text "lines" — one per row, across
314
+ * every table discovered in the file (see discoverTableNames()). Same
315
+ * status vocabulary and same iterate()-with-periodic-yield-and-deadline-
316
+ * check approach as cursor.js's and crush.js's readLines(), for the same
317
+ * reason: node:sqlite is fully synchronous, so a wall-clock deadline can
318
+ * only be enforced between rows, not preemptively mid-row. See cursor.js's
319
+ * own docstring for the full reasoning; not re-derived here since nothing
320
+ * about it is Warp-specific.
321
+ */
322
+ async function readLines(file) {
323
+ const DB = getDatabaseSync();
324
+ if (!DB) return { lines: [], status: "failed", bytesRead: 0 };
325
+
326
+ let stat;
327
+ try { stat = fs.statSync(file); }
328
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
329
+ if (stat.size > MAX_DB_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
330
+
331
+ let db;
332
+ try {
333
+ db = new DB(file, { readOnly: true });
334
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
335
+ } catch {
336
+ // Deleted between files() and this call, a corrupt/non-SQLite file, or
337
+ // Warp itself holding a lock this readonly open can't get past within
338
+ // BUSY_TIMEOUT_MS (plausible: Warp may be running and actively writing
339
+ // block/session data) — all genuinely "could not read this," not "read
340
+ // it, found nothing." Status "failed" keeps that distinction honest.
341
+ return { lines: [], status: "failed", bytesRead: 0 };
342
+ }
343
+
344
+ const tables = discoverTableNames(db);
345
+
346
+ const lines = [];
347
+ let bytesRead = 0;
348
+ const deadline = Date.now() + READ_TIMEOUT_MS;
349
+ let timedOut = false;
350
+ let sawError = false;
351
+
352
+ for (const table of tables) {
353
+ let rows;
354
+ try {
355
+ rows = db.prepare(`SELECT * FROM ${quoteIdent(table)}`).iterate();
356
+ } catch {
357
+ // A table sqlite_master listed but that fails to actually SELECT from
358
+ // (a view masquerading as a table entry, a table dropped mid-scan) —
359
+ // not a read failure for the OTHER tables, so just move on.
360
+ continue;
361
+ }
362
+
363
+ let n = 0;
364
+ try {
365
+ for (const row of rows) {
366
+ const text = rowToLine(row);
367
+ lines.push(text);
368
+ bytesRead += Buffer.byteLength(text, "utf-8");
369
+ n++;
370
+ if (n % YIELD_EVERY_N_ROWS === 0) {
371
+ await new Promise((resolve) => setImmediate(resolve));
372
+ if (Date.now() > deadline) { timedOut = true; break; }
373
+ }
374
+ }
375
+ } catch {
376
+ // A row iterator can itself throw partway (e.g. a corrupted page hit
377
+ // mid-scan) — whatever WAS read before that is real content, kept the
378
+ // same way claude-code.js keeps a partial read rather than discarding it.
379
+ sawError = true;
380
+ }
381
+ if (timedOut) break;
382
+ }
383
+
384
+ try { db.close(); } catch { /* best-effort close; nothing left to do if this fails */ }
385
+
386
+ // sqlite_master itself failed, or listed no tables at all — opened fine as
387
+ // SQLite but nothing in it could be enumerated, a real "could not extract
388
+ // anything," not the same as "extracted zero real rows."
389
+ if (tables.length === 0) return { lines: [], status: "failed", bytesRead: 0 };
390
+ if (sawError && lines.length === 0) return { lines: [], status: "failed", bytesRead };
391
+ if (timedOut || sawError) return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
392
+ return { lines, status: "complete", bytesRead };
393
+ }
394
+
395
+ module.exports = { id, label, available, unavailableReason, files, readLines };
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+
7
+ /**
8
+ * Windsurf (Codeium's AI IDE) Cascade agent conversation history.
9
+ *
10
+ * VERIFICATION STATUS (read this before trusting anything below):
11
+ * multi-source-corroborated-but-UNVERIFIED. Windsurf is not installed on
12
+ * the machine this adapter was built on (checked: no /Applications/Windsurf.app,
13
+ * no ~/.codeium, no ~/Library/Application Support/Windsurf, no ~/.windsurf,
14
+ * no ~/.config/Windsurf) — there was no real install available to confirm
15
+ * this against, unlike claude-code.js. Treat findings from this source with
16
+ * that in mind until someone with Windsurf installed confirms it against
17
+ * real data (see CONTRIBUTING.md).
18
+ *
19
+ * Windsurf is a VS Code fork, but — deliberately verified rather than
20
+ * assumed, per this adapter's brief — its Cascade conversation history is
21
+ * NOT stored the way Cursor's chat/composer data is (VS Code's per-profile
22
+ * `state.vscdb` SQLite file). Instead, Cascade keeps its own store, separate
23
+ * from the standard VS Code `User/globalStorage` directory entirely:
24
+ *
25
+ * ~/.codeium/windsurf/cascade/**\/*.pb — Cascade conversation history
26
+ * ~/.codeium/windsurf/memories/**\/*.pb — Cascade's auto-generated
27
+ * per-workspace memories (also
28
+ * user-authored/session-derived
29
+ * content, same store convention)
30
+ *
31
+ * identically on macOS and Linux (NOT XDG_CONFIG_HOME-aware — every source
32
+ * below agrees on the literal `~/.codeium/windsurf/...` path on both OSes,
33
+ * consistent with `~/.codeium/` predating Windsurf-the-IDE as Codeium's one
34
+ * shared per-user directory across all its editor plugins), and at
35
+ * `%USERPROFILE%\.codeium\windsurf\...` on Windows.
36
+ *
37
+ * Sources cross-checked (2026-09), most authoritative first:
38
+ * 1. Official docs — https://docs.windsurf.com/windsurf/cascade/memories
39
+ * redirects (307, Windsurf/Codeium having been folded into Cognition's
40
+ * Devin product line) to https://docs.devin.ai/desktop/cascade/memories,
41
+ * which states verbatim: "Cascade's autogenerated memories are
42
+ * associated with the workspace they were created in and are stored
43
+ * locally in `~/.codeium/windsurf/memories/`."
44
+ * 2. github.com/1837620622/devin-tools (fix-devin-mac.sh, formerly a
45
+ * Windsurf-focused maintenance script, still shipping fix-windsurf-*.sh
46
+ * entry points) — a real, detailed cleanup/repair tool that treats
47
+ * `~/.codeium/windsurf/cascade/*.pb` as conversation history it must
48
+ * explicitly avoid deleting outside an opt-in "wipe Cascade" mode, and
49
+ * separately globs `memories/*.pb`. Also independently confirms VS
50
+ * Code's own `User/globalStorage/state.vscdb` exists in Windsurf's
51
+ * *application-support* directory as a distinct thing it reports the
52
+ * size of but never treats as chat storage — corroborating the split
53
+ * from Cascade's own `~/.codeium/windsurf/` store.
54
+ * 3. iamraghuveer.com/posts/windsurf-team-shared-memory — an independent
55
+ * write-up confirming memories live under `~/.codeium/windsurf/memories/`,
56
+ * keyed by a hash of the workspace path (hence this adapter walking
57
+ * that directory recursively rather than assuming a flat file list).
58
+ * 4. A Medium "Windsurf Internals" piece that mitmproxy'd Windsurf's own
59
+ * traffic and found its backend RPCs are literally named things like
60
+ * `exa.api_server_pb.ApiServerService/...` — corroborating (not proving)
61
+ * that Windsurf's client is built around protobuf end to end, which is
62
+ * what `.pb`-suffixed local cache files would be.
63
+ *
64
+ * What this adapter deliberately does NOT include: the `state.vscdb`-based
65
+ * approach some community MCP tools (e.g. a `windsurf-reader.ts` found on
66
+ * npm/GitHub) take, reading VS Code's `ItemTable` for keys like
67
+ * `cascade.chatdata`. That reader's own header says it "parses SQLite
68
+ * database similar to Cursor" — phrasing that reads as an assumption, not
69
+ * independent verification — and a sibling tool's Windsurf extractor reuses
70
+ * the literal table name `cursorDiskKV` (Cursor's own internal name) for
71
+ * Windsurf, which is implausible as Windsurf's own schema and looks like
72
+ * uncritical copy-paste from Cursor's adapter rather than a checked fact.
73
+ * Shipping that guess would risk exactly the "checks the wrong place,
74
+ * reports clean" failure this project exists to avoid. If a future
75
+ * contributor confirms real chat content actually lives in Windsurf's
76
+ * state.vscdb too, add it then — with that verification.
77
+ *
78
+ * The internal byte layout of a `.pb` file is unverified (no accessible
79
+ * .proto schema was found) and is NOT relied on here — same tolerance
80
+ * cursor.js documents for opaque SQLite blob values: this scanner matches
81
+ * raw text regardless of the structure it came from. A `.pb` file's bytes
82
+ * are decoded 1:1 via "latin1" (no lossy re-encoding, unlike "utf-8" on
83
+ * non-UTF-8 binary) and treated as one long scannable string, protobuf's
84
+ * wire format stores string/bytes field values as raw, unmodified bytes, so
85
+ * a leaked secret embedded in one is still present, contiguous, and
86
+ * pattern-matchable in that string — the same reasoning that lets `strings`
87
+ * find text inside arbitrary binaries.
88
+ */
89
+ function windsurfRoot() {
90
+ const home = os.homedir();
91
+ if (process.platform === "win32") {
92
+ const userProfile = process.env.USERPROFILE || home;
93
+ return path.join(userProfile, ".codeium", "windsurf");
94
+ }
95
+ // macOS and Linux: identical, non-XDG-aware path — see the sources above.
96
+ return path.join(home, ".codeium", "windsurf");
97
+ }
98
+
99
+ const ROOT = windsurfRoot();
100
+
101
+ // The two subdirectories confirmed (see docstring) to hold Cascade
102
+ // conversation-derived `.pb` content. Order doesn't matter; both are walked.
103
+ const SUBDIRS = ["cascade", "memories"];
104
+
105
+ // Bounds for readLines(). No real `.pb` file has been observed (no real
106
+ // install to observe one against) — this reuses claude-code.js's generous
107
+ // 2GB backstop rather than inventing a number with no evidence behind it.
108
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024;
109
+ const READ_TIMEOUT_MS = 60_000;
110
+
111
+ // Recursion guard for walking cascade/memories — memories is documented to
112
+ // nest by a per-workspace hash (see source 3 above); depth is capped, and
113
+ // symlinked subdirectories are de-duped by real path, purely to bound a
114
+ // pathological or cyclic layout rather than because deep nesting is expected.
115
+ const MAX_WALK_DEPTH = 12;
116
+
117
+ function id() { return "windsurf"; }
118
+ function label() { return "Windsurf"; }
119
+
120
+ function available() {
121
+ try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
122
+ }
123
+
124
+ /**
125
+ * Same defensive symlink-following convention as claude-code.js's
126
+ * isDirFollowingSymlink (see that file for the full reasoning) — Dirent
127
+ * reflects lstat semantics, so a symlinked cascade/memories directory (or a
128
+ * symlinked file inside one) must be resolved explicitly or it is silently
129
+ * excluded.
130
+ */
131
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
132
+ if (checkFn(dirent)) return true;
133
+ if (!dirent.isSymbolicLink()) return false;
134
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
135
+ }
136
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
137
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
138
+
139
+ /**
140
+ * Recursively yield { file, mtimeMs, sizeBytes, broken } for every `*.pb`
141
+ * file under `dir`. A dangling symlink (file or directory) is reported
142
+ * broken:true rather than silently skipped, matching claude-code.js's
143
+ * convention. `visitedRealDirs` de-dupes symlinked directories by resolved
144
+ * real path so a symlink cycle terminates instead of recursing forever.
145
+ */
146
+ function* walkPbFiles(dir, depth, visitedRealDirs) {
147
+ if (depth > MAX_WALK_DEPTH) return;
148
+
149
+ let entries;
150
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
151
+ catch { yield { file: dir, broken: true }; return; }
152
+
153
+ for (const e of entries) {
154
+ const full = path.join(dir, e.name);
155
+
156
+ if (isDirFollowingSymlink(full, e)) {
157
+ if (e.isSymbolicLink()) {
158
+ let real;
159
+ try { real = fs.realpathSync(full); }
160
+ catch { yield { file: full, broken: true }; continue; }
161
+ if (visitedRealDirs.has(real)) continue;
162
+ visitedRealDirs.add(real);
163
+ }
164
+ yield* walkPbFiles(full, depth + 1, visitedRealDirs);
165
+ continue;
166
+ }
167
+
168
+ if (!e.name.endsWith(".pb")) continue;
169
+
170
+ if (!isFileFollowingSymlink(full, e)) {
171
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
172
+ continue;
173
+ }
174
+
175
+ let stat;
176
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
177
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Yield { file, mtimeMs, sizeBytes, broken } for every `.pb` file under
183
+ * ~/.codeium/windsurf/cascade and ~/.codeium/windsurf/memories.
184
+ *
185
+ * A subdirectory that simply doesn't exist (e.g. a user who has never
186
+ * triggered an auto-generated memory) yields nothing for it — normal, not
187
+ * broken, same convention as cursor.js's statIfPresent. Only an entry that
188
+ * looked like it should resolve and didn't (chiefly a dangling symlink) is
189
+ * reported broken.
190
+ */
191
+ function* files() {
192
+ for (const sub of SUBDIRS) {
193
+ const dir = path.join(ROOT, sub);
194
+
195
+ let lst;
196
+ try { lst = fs.lstatSync(dir); }
197
+ catch { continue; } // not present — unremarkable, nothing to report
198
+
199
+ if (lst.isSymbolicLink()) {
200
+ let resolved;
201
+ try { resolved = fs.statSync(dir); }
202
+ catch { yield { file: dir, broken: true }; continue; }
203
+ if (!resolved.isDirectory()) continue; // symlink resolves, but not to a directory — out of scope
204
+ } else if (!lst.isDirectory()) {
205
+ continue; // something unexpected sits at this path — out of scope, not broken
206
+ }
207
+
208
+ yield* walkPbFiles(dir, 0, new Set());
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Read one `.pb` file as a single-element `lines` array (the whole file,
214
+ * decoded via latin1 for a lossless byte<->char mapping — see the module
215
+ * docstring for why splitting on embedded newline bytes is deliberately
216
+ * NOT done here: an incidental 0x0A inside the binary encoding must not be
217
+ * allowed to sever a secret string that happens to straddle it).
218
+ *
219
+ * Streamed and bounded exactly like claude-code.js's readLines(): a size
220
+ * cap (MAX_BYTES) checked before opening, and a read timeout enforced by
221
+ * destroying the stream, since Node's stream/readline stack has no built-in
222
+ * one. Returns { lines, status, bytesRead } with the same status vocabulary
223
+ * ("complete" | "partial" | "too-large" | "failed") and the same rule: any
224
+ * bytes that WERE read before a failure are still real content and are
225
+ * still returned, never discarded just because the read didn't finish
226
+ * cleanly.
227
+ */
228
+ async function readLines(file) {
229
+ let stat;
230
+ try { stat = fs.statSync(file); }
231
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
232
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
233
+
234
+ const chunks = [];
235
+ let bytesRead = 0;
236
+ const stream = fs.createReadStream(file);
237
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
238
+
239
+ try {
240
+ for await (const chunk of stream) {
241
+ chunks.push(chunk);
242
+ bytesRead += chunk.length;
243
+ }
244
+ const text = Buffer.concat(chunks).toString("latin1");
245
+ return { lines: text.length > 0 ? [text] : [], status: "complete", bytesRead };
246
+ } catch {
247
+ if (chunks.length === 0) return { lines: [], status: "failed", bytesRead };
248
+ const text = Buffer.concat(chunks).toString("latin1");
249
+ return { lines: [text], status: "partial", bytesRead };
250
+ } finally {
251
+ clearTimeout(timer);
252
+ stream.destroy();
253
+ }
254
+ }
255
+
256
+ module.exports = { id, label, available, files, readLines };