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,379 @@
|
|
|
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
|
+
* OpenCode (https://opencode.ai — repo moved from github.com/sst/opencode to
|
|
10
|
+
* github.com/anomalyco/opencode after the SST team rebranded to Anomaly in
|
|
11
|
+
* 2026; same project, same maintainers, old sst/opencode Docker-image-style
|
|
12
|
+
* references now point at a stale location). Not to be confused with the
|
|
13
|
+
* unrelated, much smaller opencode-ai/opencode project or with GitHub
|
|
14
|
+
* Copilot's own internal "OpenCode" naming, if any — this adapter targets
|
|
15
|
+
* the 75+-provider terminal coding agent at opencode.ai specifically.
|
|
16
|
+
*
|
|
17
|
+
* VERIFICATION STATUS: NOT checked against a real OpenCode install — neither
|
|
18
|
+
* `opencode` nor any `~/.local/share/opencode` / `~/.config/opencode`
|
|
19
|
+
* directory exists on the machine this adapter was built on (checked:
|
|
20
|
+
* PATH, mdfind, common install locations). Ships anyway per CONTRIBUTING.md
|
|
21
|
+
* rule 3 on the strength of multiple independent, corroborating sources
|
|
22
|
+
* that agree with each other and, in two cases, are the project's own real
|
|
23
|
+
* source code and its own maintainers describing a real, live bug in it:
|
|
24
|
+
*
|
|
25
|
+
* - Official docs (opencode.ai/docs/troubleshooting/): data lives under
|
|
26
|
+
* `~/.local/share/opencode/` on macOS/Linux (this is the same on macOS
|
|
27
|
+
* as on Linux — OpenCode does NOT use `~/Library/Application Support`
|
|
28
|
+
* there, confirmed independently by several GitHub issues filed
|
|
29
|
+
* specifically complaining that it doesn't follow platform convention
|
|
30
|
+
* on macOS/Windows, e.g. #18633, #8235 — a real, if debated, choice, not
|
|
31
|
+
* an assumption made here) and an equivalent path under
|
|
32
|
+
* `%USERPROFILE%` on Windows.
|
|
33
|
+
* - The project's own real source, read directly: `session.sql.ts` at
|
|
34
|
+
* github.com/anomalyco/opencode confirms a Drizzle-ORM SQLite schema
|
|
35
|
+
* (`sqliteTable(...)`) for the `session` table, and multiple real
|
|
36
|
+
* GitHub issues on the same repo (#13202 "SQLite storage layer",
|
|
37
|
+
* #13654, #12889, #34445, #21941) all independently reference the same
|
|
38
|
+
* two-format reality this adapter is built around — see below.
|
|
39
|
+
* - GitHub issue #13654, describing a real, live bug: incremental
|
|
40
|
+
* upgraders can end up with the pre-SQLite storage format never
|
|
41
|
+
* migrated, leaving JSON session files "permanently orphaned" on disk;
|
|
42
|
+
* the issue explicitly quotes their real location as
|
|
43
|
+
* `~/.local/share/opencode/storage/session/*.json`. Issue #12889
|
|
44
|
+
* ("NotFoundError on startup: session JSON files not found in
|
|
45
|
+
* storage") independently corroborates the same `storage/` JSON layout
|
|
46
|
+
* from the other direction (a user hitting the gap this adapter reads
|
|
47
|
+
* around). A different official-docs excerpt additionally describes a
|
|
48
|
+
* project-scoped variant of the same idea — `<project-slug>/storage/`
|
|
49
|
+
* for a git repo, `global/storage/` otherwise — i.e. the exact nesting
|
|
50
|
+
* under `storage/` has moved across versions. Both shapes share one
|
|
51
|
+
* trait this adapter relies on instead of picking one to hardcode: the
|
|
52
|
+
* content-bearing files always sit under a directory literally named
|
|
53
|
+
* `storage`, at some depth under the OpenCode data root.
|
|
54
|
+
*
|
|
55
|
+
* Given that version drift (confirmed by the project's own migration code
|
|
56
|
+
* existing at all, and by the incremental-upgrade bug above meaning both
|
|
57
|
+
* formats really can coexist on one real machine), this source reads BOTH,
|
|
58
|
+
* the same call cursor.js makes for its own two-copy, moved-across-versions
|
|
59
|
+
* ambiguity:
|
|
60
|
+
* - `<DATA_DIR>/opencode.db` — current format, read via node:sqlite.
|
|
61
|
+
* - every `*.json` file under any directory literally named `storage`,
|
|
62
|
+
* anywhere under `<DATA_DIR>` — legacy/orphaned format.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately NOT read: `<DATA_DIR>/auth.json` (OpenCode's own provider
|
|
65
|
+
* API key vault — the user's intentionally configured credentials, not a
|
|
66
|
+
* leaked one; scanning it would just re-report keys the user put there on
|
|
67
|
+
* purpose) or `<DATA_DIR>/opencode.json` / `log/` (config and application
|
|
68
|
+
* logs, not conversation content) — mirroring how claude-code.js and
|
|
69
|
+
* cursor.js each stay scoped to actual transcript data rather than a
|
|
70
|
+
* tool's entire state directory. Since none of these live under a
|
|
71
|
+
* `storage`-named directory, the walk below already excludes them
|
|
72
|
+
* structurally, not just by convention.
|
|
73
|
+
*
|
|
74
|
+
* The SQLite schema itself is read generically (see readDbFile() below)
|
|
75
|
+
* rather than hardcoding table/column names beyond `session` (the one
|
|
76
|
+
* table this research could confirm directly from source): the exact
|
|
77
|
+
* `message`/`part` table names and columns could not be confirmed against
|
|
78
|
+
* either a real install or the actual message.sql.ts/part.sql.ts source
|
|
79
|
+
* (GitHub's raw/blob endpoints were unreachable during this research), and
|
|
80
|
+
* cursor.js's own docstring already makes the case for why guessing at a
|
|
81
|
+
* key/column allowlist is the wrong tradeoff when the schema is confirmed
|
|
82
|
+
* to have moved before and code review can't check it against a real file.
|
|
83
|
+
* A generic per-table, per-column text scan can't go stale the way a
|
|
84
|
+
* specific column list can.
|
|
85
|
+
*
|
|
86
|
+
* If you have OpenCode installed, the most useful thing you can do is run
|
|
87
|
+
* `residoo scan` and confirm `sourcesScanned`/`filesScanned` look right for
|
|
88
|
+
* what you know is actually on disk under your OpenCode data directory,
|
|
89
|
+
* then report back either way — see CONTRIBUTING.md.
|
|
90
|
+
*/
|
|
91
|
+
function opencodeDataDir() {
|
|
92
|
+
// XDG_DATA_HOME is honored for OpenCode's config dir per its own docs
|
|
93
|
+
// (opencode.ai/docs — XDG_CONFIG_HOME is explicitly documented there) and
|
|
94
|
+
// the project's data dir follows the same XDG-first convention (per the
|
|
95
|
+
// GitHub issues above discussing XDG_DATA_HOME for this exact directory).
|
|
96
|
+
// Same reasoning as cursor.js honoring XDG_CONFIG_HOME: the tool's own
|
|
97
|
+
// documented override, not a guess.
|
|
98
|
+
const home = os.homedir();
|
|
99
|
+
const dataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
100
|
+
return path.join(dataHome, "opencode");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DATA_DIR = opencodeDataDir();
|
|
104
|
+
const DB_FILE = path.join(DATA_DIR, "opencode.db");
|
|
105
|
+
|
|
106
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same reasoning as claude-code.js/codex-cli.js
|
|
107
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
108
|
+
const MAX_WALK_DEPTH = 12; // see codex-cli.js's identical constant for why this exists
|
|
109
|
+
|
|
110
|
+
// Same backstop-not-evidence caveat as cursor.js's MAX_DB_BYTES: no real
|
|
111
|
+
// opencode.db has been observed during this research to size this against.
|
|
112
|
+
const MAX_DB_BYTES = 512 * 1024 * 1024;
|
|
113
|
+
const BUSY_TIMEOUT_MS = 5_000;
|
|
114
|
+
const YIELD_EVERY_N_ROWS = 500;
|
|
115
|
+
|
|
116
|
+
function id() { return "opencode"; }
|
|
117
|
+
function label() { return "OpenCode"; }
|
|
118
|
+
|
|
119
|
+
function dataDirExists() {
|
|
120
|
+
try { return fs.statSync(DATA_DIR).isDirectory(); } catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Unlike cursor.js, availability here does NOT depend on node:sqlite being
|
|
125
|
+
* present: the legacy JSON-under-storage/ format (see module docstring) is
|
|
126
|
+
* plain text, readable with zero special modules, on any Node version this
|
|
127
|
+
* project supports. Gating the whole source on sqlite would silently drop
|
|
128
|
+
* real, scannable content for exactly the incremental-upgrade users GitHub
|
|
129
|
+
* issue #13654 describes — orphaned JSON sessions with no opencode.db ever
|
|
130
|
+
* written, or a Node runtime too old for node:sqlite. sqlite is required
|
|
131
|
+
* only for the opencode.db file specifically; see readDbFile()'s own
|
|
132
|
+
* "failed" return when it's unavailable, surfaced per-file rather than by
|
|
133
|
+
* hiding the whole source.
|
|
134
|
+
*/
|
|
135
|
+
function available() {
|
|
136
|
+
return dataDirExists();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Lazily loaded exactly like cursor.js's getDatabaseSync() — see that
|
|
141
|
+
* file's docstring for the full reasoning (avoid the one-time
|
|
142
|
+
* ExperimentalWarning node:sqlite prints from being paid by every user on
|
|
143
|
+
* every invocation, since index.js requires every source unconditionally).
|
|
144
|
+
* Duplicated rather than shared, per this project's one-file-per-source
|
|
145
|
+
* convention.
|
|
146
|
+
*/
|
|
147
|
+
let sqliteRequireAttempted = false;
|
|
148
|
+
let DatabaseSync = null;
|
|
149
|
+
function getDatabaseSync() {
|
|
150
|
+
if (!sqliteRequireAttempted) {
|
|
151
|
+
sqliteRequireAttempted = true;
|
|
152
|
+
try { ({ DatabaseSync } = require("node:sqlite")); }
|
|
153
|
+
catch { DatabaseSync = null; }
|
|
154
|
+
}
|
|
155
|
+
return DatabaseSync;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Same lstat-vs-stat symlink-following pattern duplicated across every source in this project. */
|
|
159
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
160
|
+
if (checkFn(dirent)) return true;
|
|
161
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
162
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
163
|
+
}
|
|
164
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
165
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Recursively walk `dir` looking for directories literally named `storage`;
|
|
169
|
+
* within each one found, recursively yield every `*.json` file underneath
|
|
170
|
+
* it (any depth) as a { file, mtimeMs, sizeBytes, broken } entry. A
|
|
171
|
+
* directory named anything else is only ever a waypoint down to a possible
|
|
172
|
+
* `storage` dir, never scanned itself — this is what keeps auth.json,
|
|
173
|
+
* opencode.json, and log/ out of scope structurally (see module docstring).
|
|
174
|
+
*/
|
|
175
|
+
function* walkForStorageJson(dir, depth, insideStorage) {
|
|
176
|
+
if (depth > MAX_WALK_DEPTH) return;
|
|
177
|
+
let entries;
|
|
178
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
179
|
+
catch { return; }
|
|
180
|
+
|
|
181
|
+
for (const e of entries) {
|
|
182
|
+
const full = path.join(dir, e.name);
|
|
183
|
+
|
|
184
|
+
if (e.isDirectory()) {
|
|
185
|
+
yield* walkForStorageJson(full, depth + 1, insideStorage || e.name === "storage");
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (e.isFile()) {
|
|
189
|
+
if (insideStorage && e.name.endsWith(".json")) {
|
|
190
|
+
let stat;
|
|
191
|
+
try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
|
|
192
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
193
|
+
}
|
|
194
|
+
continue; // a real file outside storage/, or not .json — out of scope, not broken
|
|
195
|
+
}
|
|
196
|
+
if (e.isSymbolicLink()) {
|
|
197
|
+
// Resolve once and treat as whichever kind it turns out to be — checked
|
|
198
|
+
// BEFORE any insideStorage gating, deliberately: a dangling symlink here
|
|
199
|
+
// might have been the very `storage` directory (or a path down to one)
|
|
200
|
+
// this walk exists to find, so it must be reported regardless of
|
|
201
|
+
// whether we've already stepped inside a `storage` dir by this point.
|
|
202
|
+
// An earlier version of this function checked `insideStorage` first,
|
|
203
|
+
// which let exactly this case (a dangling symlink encountered before
|
|
204
|
+
// entering storage/) fall through and vanish silently — the same class
|
|
205
|
+
// of bug claude-code.js's own docstring calls out and fixes.
|
|
206
|
+
if (isDirFollowingSymlink(full, e)) {
|
|
207
|
+
yield* walkForStorageJson(full, depth + 1, insideStorage || e.name === "storage");
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (isFileFollowingSymlink(full, e)) {
|
|
211
|
+
if (insideStorage && e.name.endsWith(".json")) {
|
|
212
|
+
try {
|
|
213
|
+
const stat = fs.statSync(full);
|
|
214
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
215
|
+
} catch { yield { file: full, broken: true }; }
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
yield { file: full, broken: true }; // dangling — resolves to neither a directory nor a file
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Neither directory, file, nor symlink (a device, socket, etc.) — genuinely out of scope.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for opencode.db (if present)
|
|
228
|
+
* and every legacy JSON session/message/part file under any `storage`
|
|
229
|
+
* directory anywhere below DATA_DIR. Purely a filesystem walk + stat, same
|
|
230
|
+
* division of labour as cursor.js's files() — never opens the database, so
|
|
231
|
+
* this works even without node:sqlite; only readLines() needs it.
|
|
232
|
+
*/
|
|
233
|
+
function* files() {
|
|
234
|
+
let lst;
|
|
235
|
+
try { lst = fs.lstatSync(DB_FILE); }
|
|
236
|
+
catch { lst = null; }
|
|
237
|
+
|
|
238
|
+
if (lst) {
|
|
239
|
+
if (lst.isSymbolicLink()) {
|
|
240
|
+
try {
|
|
241
|
+
const st = fs.statSync(DB_FILE);
|
|
242
|
+
if (st.isFile()) yield { file: DB_FILE, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
243
|
+
else yield { file: DB_FILE, broken: true };
|
|
244
|
+
} catch {
|
|
245
|
+
yield { file: DB_FILE, broken: true };
|
|
246
|
+
}
|
|
247
|
+
} else if (lst.isFile()) {
|
|
248
|
+
yield { file: DB_FILE, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
yield* walkForStorageJson(DATA_DIR, 0, false);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Same string/Uint8Array decode as cursor.js's valueToText — see that file's docstring for why. */
|
|
256
|
+
function valueToText(value) {
|
|
257
|
+
if (typeof value === "string") return value;
|
|
258
|
+
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf-8");
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Read opencode.db generically: every user table in sqlite_master, every
|
|
264
|
+
* column of every row, each non-null string/blob value becoming one scanned
|
|
265
|
+
* line (see module docstring for why table/column names aren't hardcoded
|
|
266
|
+
* beyond knowing `session` exists). Row-by-row with a periodic yield-and-
|
|
267
|
+
* deadline-check exactly like cursor.js's readLines, for the same reason:
|
|
268
|
+
* node:sqlite's DatabaseSync is fully synchronous, so this is the only
|
|
269
|
+
* preemption point available without adding a dependency.
|
|
270
|
+
*/
|
|
271
|
+
async function readDbFile(file) {
|
|
272
|
+
const DB = getDatabaseSync();
|
|
273
|
+
if (!DB) return { lines: [], status: "failed", bytesRead: 0 };
|
|
274
|
+
|
|
275
|
+
let stat;
|
|
276
|
+
try { stat = fs.statSync(file); }
|
|
277
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
278
|
+
if (stat.size > MAX_DB_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
279
|
+
|
|
280
|
+
let db;
|
|
281
|
+
try {
|
|
282
|
+
db = new DB(file, { readOnly: true });
|
|
283
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
284
|
+
} catch {
|
|
285
|
+
return { lines: [], status: "failed", bytesRead: 0 };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let tables;
|
|
289
|
+
try {
|
|
290
|
+
tables = db.prepare(
|
|
291
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_\\_drizzle%' ESCAPE '\\'"
|
|
292
|
+
).all().map((r) => r.name);
|
|
293
|
+
} catch {
|
|
294
|
+
try { db.close(); } catch { /* best-effort */ }
|
|
295
|
+
return { lines: [], status: "failed", bytesRead: 0 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const lines = [];
|
|
299
|
+
let bytesRead = 0;
|
|
300
|
+
const deadline = Date.now() + READ_TIMEOUT_MS;
|
|
301
|
+
let timedOut = false;
|
|
302
|
+
let sawError = false;
|
|
303
|
+
|
|
304
|
+
for (const table of tables) {
|
|
305
|
+
let rows;
|
|
306
|
+
try {
|
|
307
|
+
// Table names come from sqlite_master itself, not external input, but
|
|
308
|
+
// they're still quoted defensively rather than trusted to be
|
|
309
|
+
// bare-word-safe.
|
|
310
|
+
rows = db.prepare(`SELECT * FROM "${table.replace(/"/g, '""')}"`).iterate();
|
|
311
|
+
} catch {
|
|
312
|
+
continue; // this table vanished or is a view/virtual table that doesn't support this — move on
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let n = 0;
|
|
316
|
+
try {
|
|
317
|
+
for (const row of rows) {
|
|
318
|
+
for (const key in row) {
|
|
319
|
+
const text = valueToText(row[key]);
|
|
320
|
+
if (text) { lines.push(text); bytesRead += Buffer.byteLength(text, "utf-8"); }
|
|
321
|
+
}
|
|
322
|
+
n++;
|
|
323
|
+
if (n % YIELD_EVERY_N_ROWS === 0) {
|
|
324
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
325
|
+
if (Date.now() > deadline) { timedOut = true; break; }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
sawError = true;
|
|
330
|
+
}
|
|
331
|
+
if (timedOut) break;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try { db.close(); } catch { /* best-effort */ }
|
|
335
|
+
|
|
336
|
+
if (sawError && lines.length === 0) return { lines: [], status: "failed", bytesRead };
|
|
337
|
+
if (timedOut || sawError) return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
338
|
+
return { lines, status: "complete", bytesRead };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read one legacy JSON session/message/part file as raw text lines —
|
|
343
|
+
* identical streaming strategy to claude-code.js's readLines (a JSON file
|
|
344
|
+
* on disk is still line-delimited text; the pattern-matcher works on raw
|
|
345
|
+
* text regardless of what it parses as). Duplicated rather than shared,
|
|
346
|
+
* per this project's convention.
|
|
347
|
+
*/
|
|
348
|
+
async function readJsonFile(file) {
|
|
349
|
+
let stat;
|
|
350
|
+
try { stat = fs.statSync(file); }
|
|
351
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
352
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
353
|
+
|
|
354
|
+
const lines = [];
|
|
355
|
+
let bytesRead = 0;
|
|
356
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
357
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
358
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
for await (const line of rl) {
|
|
362
|
+
lines.push(line);
|
|
363
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1;
|
|
364
|
+
}
|
|
365
|
+
return { lines, status: "complete", bytesRead };
|
|
366
|
+
} catch {
|
|
367
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
368
|
+
} finally {
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
rl.close();
|
|
371
|
+
stream.destroy();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readLines(file) {
|
|
376
|
+
return file === DB_FILE ? readDbFile(file) : readJsonFile(file);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
* OpenHands (github.com/OpenHands/OpenHands, 85,900+ stars) local conversation
|
|
10
|
+
* storage.
|
|
11
|
+
*
|
|
12
|
+
* VERIFICATION STATUS: corroborated by reading the actual, current source code
|
|
13
|
+
* of two real repositories in the OpenHands org (fetched directly from GitHub
|
|
14
|
+
* during this source's research, base64-decoded, not inferred from a blog
|
|
15
|
+
* post) — NOT verified against a real OpenHands install on the machine this
|
|
16
|
+
* source was built on (OpenHands was not installed there; see CONTRIBUTING.md).
|
|
17
|
+
*
|
|
18
|
+
* 1. github.com/OpenHands/OpenHands-CLI — `openhands_cli/locations.py`
|
|
19
|
+
* defines `get_persistence_dir()` as `$OPENHANDS_PERSISTENCE_DIR` or
|
|
20
|
+
* `~/.openhands`, and `get_conversations_dir()` as
|
|
21
|
+
* `<persistence_dir>/conversations`. `openhands_cli/conversations/
|
|
22
|
+
* store/local.py`'s `LocalFileStore` (the CLI's on-disk conversation
|
|
23
|
+
* store) confirms the concrete layout used to build ROOT below:
|
|
24
|
+
* `<conversations_dir>/<conversation_id>/events/event-*.json`, one JSON
|
|
25
|
+
* object per file, plus a `metadata.json`-style summary this source does
|
|
26
|
+
* NOT rely on (it derives everything from the event files themselves).
|
|
27
|
+
* NOTE: as of this research, OpenHands-CLI's own README marks the
|
|
28
|
+
* project "no longer actively maintained," pointing users at "Agent
|
|
29
|
+
* Canvas" (the browser-based control surface in the main OpenHands/
|
|
30
|
+
* OpenHands repo) instead.
|
|
31
|
+
* 2. github.com/OpenHands/software-agent-sdk (1,000+ stars, actively
|
|
32
|
+
* maintained — this is the SDK OpenHands-CLI itself is "Powered by," and
|
|
33
|
+
* the same SDK Agent Canvas's agent-server is built on) confirms the
|
|
34
|
+
* event-file naming convention still current today:
|
|
35
|
+
* `openhands-sdk/openhands/sdk/conversation/persistence_const.py`
|
|
36
|
+
* defines `EVENTS_DIR = "events"` and
|
|
37
|
+
* `EVENT_FILE_PATTERN = "event-{idx:05d}-{event_id}.json"`. A shipped
|
|
38
|
+
* example, `examples/01_standalone_sdk/55_persistent_memory.py`, also
|
|
39
|
+
* independently confirms `~/.openhands/` (home-anchored, not
|
|
40
|
+
* workspace-anchored) as the SDK's own convention for user-tier
|
|
41
|
+
* persistent state, matching the CLI's `get_persistence_dir()`.
|
|
42
|
+
*
|
|
43
|
+
* What this source does NOT claim: that `~/.openhands/conversations` is
|
|
44
|
+
* necessarily where the currently-recommended "Agent Canvas" product (which
|
|
45
|
+
* can also run inside Docker, where its FILE_STORE_PATH may point inside the
|
|
46
|
+
* container rather than the host) writes when run that way. What IS shared
|
|
47
|
+
* and confirmed across every variant researched is the `~/.openhands` home
|
|
48
|
+
* directory as OpenHands' persistence root, and the events/event-*.json shape
|
|
49
|
+
* for conversation storage. Scanning that root, if present on this machine,
|
|
50
|
+
* degrades gracefully (files() below simply finds nothing extra) rather than
|
|
51
|
+
* scanning the wrong place, if some other run mode used a different root
|
|
52
|
+
* entirely (e.g. purely inside an ephemeral Docker container never mounted to
|
|
53
|
+
* the host).
|
|
54
|
+
*
|
|
55
|
+
* Each event-*.json file is a single JSON object (see the pattern above), not
|
|
56
|
+
* JSONL — but scan.js only wants raw text lines, and a JSON file (minified or
|
|
57
|
+
* pretty-printed) is exactly that, so this source reuses claude-code.js's
|
|
58
|
+
* plain line-streaming readLines() unmodified in spirit.
|
|
59
|
+
*/
|
|
60
|
+
const HOME = os.homedir();
|
|
61
|
+
const PERSISTENCE_DIR = path.join(HOME, ".openhands");
|
|
62
|
+
const CONVERSATIONS_DIR = path.join(PERSISTENCE_DIR, "conversations");
|
|
63
|
+
|
|
64
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same generous backstop as claude-code.js;
|
|
65
|
+
// real event files are tiny (one event each), this
|
|
66
|
+
// is a pathological-file guard, not a tuned limit.
|
|
67
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
68
|
+
|
|
69
|
+
function id() { return "openhands"; }
|
|
70
|
+
function label() { return "OpenHands"; }
|
|
71
|
+
|
|
72
|
+
function available() {
|
|
73
|
+
try { return fs.statSync(PERSISTENCE_DIR).isDirectory(); } catch { return false; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Same defensive symlink-following helpers as claude-code.js — see that
|
|
78
|
+
* file's docstring for the full reasoning. Duplicated rather than imported:
|
|
79
|
+
* every source in this project is meant to be a small, self-contained file a
|
|
80
|
+
* reviewer can audit on its own (see cursor.js's docstring for the same
|
|
81
|
+
* design note).
|
|
82
|
+
*/
|
|
83
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
84
|
+
if (checkFn(dirent)) return true;
|
|
85
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
86
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
87
|
+
}
|
|
88
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
89
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Yield entries for every plain file directly inside `dir` whose name passes
|
|
93
|
+
* `matchFn`, following symlinks the same way claude-code.js's files() does,
|
|
94
|
+
* and reporting a symlink that resolves to neither a file nor a directory as
|
|
95
|
+
* `broken: true` rather than silently skipping it. Not recursive — every
|
|
96
|
+
* caller here already knows the exact directory it wants to list.
|
|
97
|
+
*/
|
|
98
|
+
function* filesInDir(dir, matchFn) {
|
|
99
|
+
let entries;
|
|
100
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
101
|
+
catch { return; } // directory doesn't exist (e.g. no events/ yet) — normal, not broken
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
const full = path.join(dir, e.name);
|
|
104
|
+
if (isDirFollowingSymlink(full, e)) continue; // a directory where a file was expected — out of scope
|
|
105
|
+
const isFile = isFileFollowingSymlink(full, e);
|
|
106
|
+
if (!isFile) {
|
|
107
|
+
if (e.isSymbolicLink()) yield { file: full, broken: true };
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!matchFn(e.name)) continue;
|
|
111
|
+
let stat;
|
|
112
|
+
try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
|
|
113
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every conversation's
|
|
119
|
+
* metadata.json and event-*.json files under ~/.openhands/conversations.
|
|
120
|
+
*
|
|
121
|
+
* Structure (confirmed by source, see docstring above):
|
|
122
|
+
* conversations/<conversation_id>/metadata.json (optional, small)
|
|
123
|
+
* conversations/<conversation_id>/events/event-*.json (one per event)
|
|
124
|
+
*/
|
|
125
|
+
function* files() {
|
|
126
|
+
let conversationDirs;
|
|
127
|
+
try { conversationDirs = fs.readdirSync(CONVERSATIONS_DIR, { withFileTypes: true }); }
|
|
128
|
+
catch { return; }
|
|
129
|
+
|
|
130
|
+
for (const conv of conversationDirs) {
|
|
131
|
+
const convDir = path.join(CONVERSATIONS_DIR, conv.name);
|
|
132
|
+
const isDir = isDirFollowingSymlink(convDir, conv);
|
|
133
|
+
if (!isDir) {
|
|
134
|
+
if (conv.isSymbolicLink()) yield { file: convDir, broken: true };
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
yield* filesInDir(convDir, (name) => name === "metadata.json");
|
|
139
|
+
|
|
140
|
+
const eventsDir = path.join(convDir, "events");
|
|
141
|
+
yield* filesInDir(eventsDir, (name) => name.startsWith("event-") && name.endsWith(".json"));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read one file (a single JSON object, metadata or event) as raw text lines.
|
|
147
|
+
* Identical streaming/timeout/partial-read discipline to claude-code.js's
|
|
148
|
+
* readLines() — see that file's docstring for the reasoning; not repeated
|
|
149
|
+
* here since these files are individually tiny and the 818MB failure mode
|
|
150
|
+
* that motivated streaming there doesn't apply, but there is no reason to be
|
|
151
|
+
* less careful about a hung read or a mid-read failure just because the
|
|
152
|
+
* common case is small.
|
|
153
|
+
*/
|
|
154
|
+
async function readLines(file) {
|
|
155
|
+
let stat;
|
|
156
|
+
try { stat = fs.statSync(file); }
|
|
157
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
158
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
159
|
+
|
|
160
|
+
const lines = [];
|
|
161
|
+
let bytesRead = 0;
|
|
162
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
163
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
164
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
for await (const line of rl) {
|
|
168
|
+
lines.push(line);
|
|
169
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1;
|
|
170
|
+
}
|
|
171
|
+
return { lines, status: "complete", bytesRead };
|
|
172
|
+
} catch {
|
|
173
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
rl.close();
|
|
177
|
+
stream.destroy();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { id, label, available, files, readLines };
|