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