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,153 @@
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
+ * Factory AI's "droid" CLI (docs.factory.ai/droid-cli) local session
10
+ * transcripts.
11
+ *
12
+ * VERIFICATION STATUS: corroborated by three independent sources, but NOT
13
+ * checked against a real droid install on the machine this source was built
14
+ * on (droid was not installed there — no `~/.factory` directory exists; see
15
+ * CONTRIBUTING.md).
16
+ *
17
+ * 1. Factory's own official docs (docs.factory.ai/cli/configuration/settings
18
+ * and docs.factory.ai/droid-cli/cli-reference) confirm the root itself:
19
+ * `~/.factory/settings.json` on macOS/Linux, and a documented
20
+ * `worktreeDirectory` setting defaulting to `~/.factory/worktrees` —
21
+ * i.e. `~/.factory` is definitely droid's real per-user data root, from
22
+ * Factory itself, not a guess. The official docs do NOT spell out the
23
+ * session-transcript path/format, though.
24
+ * 2. agent-safehouse.dev's sandboxed filesystem-inspection report on droid
25
+ * ("Droid (Factory CLI) — Sandbox Analysis Report") — a real behavioral
26
+ * inspection of what the CLI actually writes, not a guess — documents
27
+ * session transcripts at `~/.factory/projects/<project>/<session-id>
28
+ * .jsonl` (JSONL, one JSON object per line) alongside
29
+ * `~/.factory/settings.json`, `~/.factory/mcp.json`, and
30
+ * `~/.factory/logs/`. That it independently reports the same
31
+ * settings.json/worktrees layout Factory's own docs describe is what
32
+ * gives this source's less-official reporting (the exact transcript
33
+ * path/format) real weight.
34
+ * 3. jazzyalex/agent-sessions (github.com/jazzyalex/agent-sessions, 800+
35
+ * stars) — a real, actively maintained macOS app built specifically to
36
+ * parse local AI-coding-agent session history for browsing/search —
37
+ * lists droid's session sources in its own README as BOTH
38
+ * `~/.factory/sessions` and `~/.factory/projects`, i.e. it reads two
39
+ * locations rather than committing to one, which is exactly the
40
+ * resilient stance this source also takes below (see the recursive
41
+ * `.jsonl` walk rather than a single hard-coded subpath).
42
+ *
43
+ * Given two credible-but-not-identical accounts of the exact subdirectory
44
+ * (`projects/<project>/` per the sandbox report, `sessions/` per
45
+ * agent-sessions — plausibly both real, e.g. different droid versions or
46
+ * different session kinds), this source does NOT hard-code either one.
47
+ * Instead it walks `~/.factory` recursively for any `*.jsonl` file, the same
48
+ * "match the extension, don't guess the exact subpath" tolerance
49
+ * claude-code.js applies within a single project directory. This is
50
+ * deliberately broader than strictly necessary rather than narrower: a
51
+ * `*.jsonl` file anywhere under `~/.factory` is, per every source above,
52
+ * either a real session transcript or nothing droid would plausibly put
53
+ * there at all.
54
+ */
55
+ const HOME = os.homedir();
56
+ const ROOT = path.join(HOME, ".factory");
57
+
58
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same backstop as claude-code.js.
59
+ const READ_TIMEOUT_MS = 60_000;
60
+ const MAX_WALK_DEPTH = 8; // generous bound against a symlink cycle or a pathologically deep tree
61
+
62
+ function id() { return "factory-droid"; }
63
+ function label() { return "Factory Droid CLI"; }
64
+
65
+ function available() {
66
+ try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
67
+ }
68
+
69
+ /**
70
+ * Same defensive symlink-following helpers as claude-code.js — see that
71
+ * file's docstring. Duplicated rather than imported, per this project's
72
+ * self-contained-source-file convention (see cursor.js's docstring).
73
+ */
74
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
75
+ if (checkFn(dirent)) return true;
76
+ if (!dirent.isSymbolicLink()) return false;
77
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
78
+ }
79
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
80
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
81
+
82
+ /**
83
+ * Recursively yield { file, mtimeMs, sizeBytes, broken } for every plain file
84
+ * under `dir` whose name passes `matchFn`, following symlinks (both for
85
+ * directories to descend into and files to yield) and reporting a symlink
86
+ * that resolves to neither as `broken: true` — the same convention
87
+ * claude-code.js's files() uses, generalized to arbitrary depth since
88
+ * droid's exact directory nesting isn't pinned down by the sources above
89
+ * (see the module docstring). Bounded by MAX_WALK_DEPTH against a symlink
90
+ * cycle.
91
+ */
92
+ function* walk(dir, depth, matchFn) {
93
+ if (depth > MAX_WALK_DEPTH) return;
94
+ let entries;
95
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
96
+ catch { return; }
97
+
98
+ for (const e of entries) {
99
+ const full = path.join(dir, e.name);
100
+ if (isDirFollowingSymlink(full, e)) {
101
+ yield* walk(full, depth + 1, matchFn);
102
+ continue;
103
+ }
104
+ const isFile = isFileFollowingSymlink(full, e);
105
+ if (!isFile) {
106
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
107
+ continue; // not a symlink, not a dir, not a file (e.g. a socket) — out of scope
108
+ }
109
+ if (!matchFn(e.name)) continue;
110
+ let stat;
111
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
112
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
113
+ }
114
+ }
115
+
116
+ function* files() {
117
+ yield* walk(ROOT, 0, (name) => name.endsWith(".jsonl"));
118
+ }
119
+
120
+ /**
121
+ * Read one JSONL transcript as raw text lines. Identical streaming/timeout/
122
+ * partial-read discipline to claude-code.js's readLines() — see that file's
123
+ * docstring for the full reasoning (V8 string-length ceiling, TOCTOU re-stat,
124
+ * honest partial-read status).
125
+ */
126
+ async function readLines(file) {
127
+ let stat;
128
+ try { stat = fs.statSync(file); }
129
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
130
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
131
+
132
+ const lines = [];
133
+ let bytesRead = 0;
134
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
135
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
136
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
137
+
138
+ try {
139
+ for await (const line of rl) {
140
+ lines.push(line);
141
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
142
+ }
143
+ return { lines, status: "complete", bytesRead };
144
+ } catch {
145
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
146
+ } finally {
147
+ clearTimeout(timer);
148
+ rl.close();
149
+ stream.destroy();
150
+ }
151
+ }
152
+
153
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,136 @@
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
+ * Vercel Labs' "fx" coding agent (github.com/vercel-labs/fx, Apache-2.0,
10
+ * released 2026-08-17) local session transcripts.
11
+ *
12
+ * VERIFICATION STATUS: read directly from the actual, current shipped source
13
+ * (Zig) of vercel-labs/fx@main (fetched from GitHub during this source's
14
+ * research) — but NOT checked against a real install on the machine this
15
+ * source was built on (no `~/.fx` directory exists there; see
16
+ * CONTRIBUTING.md). fx is a genuinely new project (released roughly two
17
+ * weeks before this source was written), so its real-world adoption is still
18
+ * emerging relative to the other sources in this project — flagged here so
19
+ * that's visible alongside the (otherwise strong) path verification.
20
+ *
21
+ * The chain of evidence, from vercel-labs/fx@main:
22
+ * - `src/core/shared/profile_paths.zig` defines
23
+ * `root_dir_name = ".fx"` and `sessions_dir_name = "sessions"`, with
24
+ * `sessionsDir()` joining `<home>/.fx/sessions` — confirmed further by
25
+ * that same file's own unit test asserting
26
+ * `rootDir(alloc, "/tmp/fake-home")` equals `"/tmp/fake-home/.fx"`.
27
+ * - `src/core/session/session_log.zig` defines the per-session file names
28
+ * used underneath that directory:
29
+ * `events_file = "events.jsonl"` (the append-only record log — every
30
+ * event, per that file's own field naming alongside `authority_file`,
31
+ * `commit_lock_file`, etc.), plus `manifest_file = "session.json"` and
32
+ * `checkpoint_file = "checkpoint.json"` (a periodic snapshot, not the
33
+ * primary record). This source targets only `events.jsonl` — the one
34
+ * file documented as holding every record, matching the "one canonical
35
+ * transcript stream" shape this project's other JSONL sources scan,
36
+ * rather than also reading the redundant checkpoint snapshots.
37
+ *
38
+ * `sessionsDir()`'s own constants give the root and the two directory names
39
+ * confirmed above; the exact per-session subdirectory naming underneath
40
+ * `sessions/` (session_layout.zig, not fetched during this research) isn't
41
+ * relied on — this source walks recursively for `events.jsonl` instead of
42
+ * assuming a fixed depth, the same tolerance claude-code.js applies to
43
+ * Claude Code's own project-slug directory names.
44
+ */
45
+ const HOME = os.homedir();
46
+ const SESSIONS_ROOT = path.join(HOME, ".fx", "sessions");
47
+
48
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same backstop as claude-code.js.
49
+ const READ_TIMEOUT_MS = 60_000;
50
+ const MAX_WALK_DEPTH = 8;
51
+
52
+ function id() { return "fx"; }
53
+ function label() { return "fx"; }
54
+
55
+ function available() {
56
+ try { return fs.statSync(SESSIONS_ROOT).isDirectory(); } catch { return false; }
57
+ }
58
+
59
+ /**
60
+ * Same defensive symlink-following helpers as claude-code.js — see that
61
+ * file's docstring. Duplicated rather than imported, per this project's
62
+ * self-contained-source-file convention (see cursor.js's docstring).
63
+ */
64
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
65
+ if (checkFn(dirent)) return true;
66
+ if (!dirent.isSymbolicLink()) return false;
67
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
68
+ }
69
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
70
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
71
+
72
+ /**
73
+ * Recursively yield { file, mtimeMs, sizeBytes, broken } for every plain file
74
+ * under `dir` whose name passes `matchFn` — see factory-droid.js's walk()
75
+ * for the identical reasoning.
76
+ */
77
+ function* walk(dir, depth, matchFn) {
78
+ if (depth > MAX_WALK_DEPTH) return;
79
+ let entries;
80
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
81
+ catch { return; }
82
+
83
+ for (const e of entries) {
84
+ const full = path.join(dir, e.name);
85
+ if (isDirFollowingSymlink(full, e)) {
86
+ yield* walk(full, depth + 1, matchFn);
87
+ continue;
88
+ }
89
+ const isFile = isFileFollowingSymlink(full, e);
90
+ if (!isFile) {
91
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
92
+ continue;
93
+ }
94
+ if (!matchFn(e.name)) continue;
95
+ let stat;
96
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
97
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
98
+ }
99
+ }
100
+
101
+ function* files() {
102
+ yield* walk(SESSIONS_ROOT, 0, (name) => name === "events.jsonl");
103
+ }
104
+
105
+ /**
106
+ * Read one events.jsonl transcript as raw text lines. Identical streaming/
107
+ * timeout/partial-read discipline to claude-code.js's readLines().
108
+ */
109
+ async function readLines(file) {
110
+ let stat;
111
+ try { stat = fs.statSync(file); }
112
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
113
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
114
+
115
+ const lines = [];
116
+ let bytesRead = 0;
117
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
118
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
119
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
120
+
121
+ try {
122
+ for await (const line of rl) {
123
+ lines.push(line);
124
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1;
125
+ }
126
+ return { lines, status: "complete", bytesRead };
127
+ } catch {
128
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
129
+ } finally {
130
+ clearTimeout(timer);
131
+ rl.close();
132
+ stream.destroy();
133
+ }
134
+ }
135
+
136
+ module.exports = { id, label, available, files, readLines };
@@ -0,0 +1,242 @@
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
+ * Gemini CLI (google-gemini/gemini-cli) session/chat transcripts.
10
+ *
11
+ * VERIFICATION STATUS (read this before trusting anything below): this
12
+ * source is corroborated by the actual current source code of the official
13
+ * google-gemini/gemini-cli repository on GitHub (fetched and read directly,
14
+ * not inferred from a description of it) plus that project's own published
15
+ * docs and multiple independent GitHub discussions/issues from real users
16
+ * describing real installs. It has NOT been checked against a real Gemini
17
+ * CLI install or real transcript content — Gemini CLI is not installed on
18
+ * the machine this adapter was built on (checked: no `gemini` on PATH, no
19
+ * `~/.gemini` directory, no `@google/gemini-cli` in global npm packages).
20
+ * If you have Gemini CLI installed, the most useful thing you can do is run
21
+ * `residoo scan` and confirm `sourcesScanned`/`filesScanned` look right for
22
+ * what you know is actually on disk under `~/.gemini/tmp`, then report back
23
+ * either way.
24
+ *
25
+ * Storage location, confirmed directly from source
26
+ * (packages/core/src/config/storage.ts, packages/core/src/utils/paths.ts,
27
+ * packages/core/src/services/chatRecordingService.ts on the `main` branch):
28
+ *
29
+ * - Base directory: `$GEMINI_CLI_HOME/.gemini` if that env var is set
30
+ * (documented in docs/reference/configuration.md — "Specifies the root
31
+ * directory for Gemini CLI's user-level configuration and storage...
32
+ * The CLI will create a `.gemini` folder inside this directory"),
33
+ * otherwise `~/.gemini` (`GEMINI_DIR = '.gemini'`, joined onto
34
+ * `os.homedir()` with no other per-OS branching — unlike Cursor, there
35
+ * is no XDG special-casing to replicate here, confirmed directly from
36
+ * `getGlobalGeminiDir()`'s source).
37
+ * - Per-project temp dir: `~/.gemini/tmp/<projectIdentifier>/`
38
+ * (`TMP_DIR_NAME = 'tmp'`, `getProjectTempDir()` joins the global temp
39
+ * dir with one path segment). The identifier scheme has itself changed
40
+ * across versions — an older SHA-256 hex hash of the project root
41
+ * (`getProjectHash()`, still present in the source) versus a newer
42
+ * "ProjectRegistry"-assigned short slug — so this source does not try
43
+ * to recompute either one; see files() below for why it just walks
44
+ * `tmp/` and treats every entry as a candidate project directory,
45
+ * independent of which identifier scheme produced its name.
46
+ * - Chat history: `<projectTempDir>/chats/`, written by
47
+ * `ChatRecordingService`. As of a PR merged into `main` on 2026-04-09
48
+ * ("feat(core): migrate chat recording to JSONL streaming", #23749),
49
+ * sessions are JSON Lines: `session-<ISO-timestamp>-<sessionId8>.jsonl`,
50
+ * one JSON record per line — a metadata record first (sessionId,
51
+ * projectHash, startTime, ...), then one record per user/model turn
52
+ * (type, content, toolCalls, thoughts, tokens, ...). Before that PR,
53
+ * the same directory held whole-session files named
54
+ * `session-*.json` instead (one pretty-printed JSON document per
55
+ * session, rewritten in full on every turn) — the loader introduced by
56
+ * that PR explicitly still reads both extensions, meaning transcripts
57
+ * from before an upgrade can still be sitting there. This source reads
58
+ * both, and does not otherwise care which shape a given file is: like
59
+ * claude-code.js, it pattern-matches raw text lines and a `.json` file
60
+ * with embedded secrets still gets scanned line-by-line even though it
61
+ * isn't itself line-delimited JSON — see readLines()'s docstring.
62
+ * - Subagent sessions nest one level deeper:
63
+ * `<projectTempDir>/chats/<sanitizedParentSessionId>/<sessionId>.jsonl`
64
+ * (confirmed in chatRecordingService.ts). files() below bounds its
65
+ * descent into `chats/` rather than hard-coding "exactly one extra
66
+ * level," so a further nesting change wouldn't silently stop being
67
+ * scanned.
68
+ *
69
+ * Deliberately OUT OF SCOPE: `<projectTempDir>/checkpoints/` (per-tool-call
70
+ * git-snapshot checkpoints created by the `/restore` workflow — a different
71
+ * subsystem from chat recording, and not confirmed to hold prompt/response
72
+ * text the way `chats/` is) and `<projectTempDir>/logs/` (this source's
73
+ * research turned up `getProjectTempLogsDir()` but no confirmation of what,
74
+ * if anything, currently writes there — plausibly OpenTelemetry/debug
75
+ * output, not conversation content). Neither is included here; scanning
76
+ * conversation transcripts is the well-corroborated claim this source
77
+ * makes, and CONTRIBUTING.md's rule against guessing applies per-location,
78
+ * not just per-tool.
79
+ *
80
+ * Sources consulted: google-gemini/gemini-cli source on GitHub
81
+ * (storage.ts, paths.ts, chatRecordingService.ts, docs/reference/
82
+ * configuration.md, all fetched from the `main` branch); geminicli.com's
83
+ * published session-management docs; GitHub discussions #3965, #4974 and
84
+ * issue #5101 (real users describing their own `~/.gemini/tmp` contents);
85
+ * issue #15292 (the JSONL-migration proposal, later implemented via #23749,
86
+ * which is what pins down the pre/post-migration file shapes above).
87
+ */
88
+ function geminiHomeDir() {
89
+ const base = process.env.GEMINI_CLI_HOME || os.homedir();
90
+ return path.join(base, ".gemini");
91
+ }
92
+
93
+ const ROOT = geminiHomeDir();
94
+ const TMP_DIR = path.join(ROOT, "tmp");
95
+ const CHAT_FILE_EXT = /\.(jsonl|json)$/i;
96
+
97
+ // How many levels deep files() will descend into a project's chats/
98
+ // directory looking for session files. The only nesting confirmed from
99
+ // source is one extra level (subagent sessions), but this is kept bounded
100
+ // rather than hard-coded at exactly 1 so a future extra level of nesting
101
+ // gets scanned rather than silently missed — and bounded at all so a
102
+ // symlink cycle under chats/ can't turn this into an infinite walk.
103
+ const MAX_CHATS_DEPTH = 4;
104
+
105
+ // Bounds for readLines() — same shape as claude-code.js's, but the actual
106
+ // number is NOT backed by a real large Gemini CLI transcript this tool was
107
+ // tested against (unlike claude-code.js's, which cites a real 818MB file);
108
+ // no Gemini CLI install was available to produce one. Treat this as a
109
+ // generous, untested backstop against a pathological file, not evidence of
110
+ // what real transcripts look like.
111
+ const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
112
+ const READ_TIMEOUT_MS = 60_000;
113
+
114
+ function id() { return "gemini-cli"; }
115
+ function label() { return "Gemini CLI"; }
116
+
117
+ function available() {
118
+ try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
119
+ }
120
+
121
+ /**
122
+ * Same defensive symlink-following pattern as claude-code.js — see that
123
+ * file's docstring for the full reasoning. Duplicated rather than imported:
124
+ * each source here is meant to be a small, self-contained file a reviewer
125
+ * can audit on its own (see CONTRIBUTING.md).
126
+ */
127
+ function isKindFollowingSymlink(fullPath, dirent, checkFn) {
128
+ if (checkFn(dirent)) return true;
129
+ if (!dirent.isSymbolicLink()) return false;
130
+ try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
131
+ }
132
+ const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
133
+ const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
134
+
135
+ /**
136
+ * Walk a `chats/` directory (or one of its subdirectories) for candidate
137
+ * session files, up to MAX_CHATS_DEPTH levels deep. A missing directory
138
+ * (e.g. a project that has never recorded a chat) yields nothing and is NOT
139
+ * reported broken — same convention cursor.js's statIfPresent() uses for
140
+ * "this path just doesn't exist yet." A directory that exists but can't be
141
+ * read, or a symlink that can't be resolved, IS reported broken — nothing
142
+ * this function declines to descend into or open is done so silently.
143
+ */
144
+ function* walkChatFiles(dir, depth) {
145
+ let entries;
146
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
147
+ catch { return; }
148
+
149
+ for (const e of entries) {
150
+ const full = path.join(dir, e.name);
151
+
152
+ if (isDirFollowingSymlink(full, e)) {
153
+ if (depth < MAX_CHATS_DEPTH) yield* walkChatFiles(full, depth + 1);
154
+ continue;
155
+ }
156
+ if (isFileFollowingSymlink(full, e)) {
157
+ if (!CHAT_FILE_EXT.test(e.name)) continue;
158
+ let stat;
159
+ try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
160
+ yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
161
+ continue;
162
+ }
163
+ // Neither resolves as a directory nor a file: a dangling symlink is the
164
+ // one case worth reporting (a stray FIFO/socket etc. sitting in here
165
+ // was never a scannable transcript in the first place).
166
+ if (e.isSymbolicLink()) yield { file: full, broken: true };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Yield { file, mtimeMs, sizeBytes, broken } for every Gemini CLI chat
172
+ * transcript found under `~/.gemini/tmp/<projectIdentifier>/chats/`.
173
+ *
174
+ * Every entry under tmp/ is treated as a candidate project directory rather
175
+ * than trying to recompute the identifier scheme (SHA-256 hash vs. registry
176
+ * slug — see the module docstring) — this mirrors cursor.js's choice to
177
+ * walk `workspaceStorage/<hash>` generically rather than recompute VS
178
+ * Code's hash function.
179
+ */
180
+ function* files() {
181
+ let idDirs;
182
+ try { idDirs = fs.readdirSync(TMP_DIR, { withFileTypes: true }); }
183
+ catch { return; }
184
+
185
+ for (const idEnt of idDirs) {
186
+ const idDir = path.join(TMP_DIR, idEnt.name);
187
+ if (!isDirFollowingSymlink(idDir, idEnt)) {
188
+ if (idEnt.isSymbolicLink()) yield { file: idDir, broken: true };
189
+ continue;
190
+ }
191
+ yield* walkChatFiles(path.join(idDir, "chats"), 0);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Read one transcript as an array of raw text lines. Identical approach to
197
+ * claude-code.js's readLines() — streamed via readline/promises (not
198
+ * readFileSync+split, for the same V8 string-length-ceiling reason), a
199
+ * generous size cap, and a hard read timeout since Node's stream/readline
200
+ * stack has no built-in one. See claude-code.js's own docstring for the
201
+ * full reasoning; not re-derived here since nothing about it is
202
+ * Gemini-CLI-specific.
203
+ *
204
+ * Works the same whether `file` is JSONL (one record per line, the current
205
+ * format) or a legacy whole-session `.json` document (pretty-printed across
206
+ * many lines) — per the adapter contract, lines don't need to be valid JSON
207
+ * individually, they just need pattern-matching against; a secret inside a
208
+ * multi-line JSON document still lands on whatever physical line it's on.
209
+ */
210
+ async function readLines(file) {
211
+ let stat;
212
+ try { stat = fs.statSync(file); }
213
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
214
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
215
+
216
+ const lines = [];
217
+ let bytesRead = 0;
218
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
219
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
220
+
221
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
222
+
223
+ try {
224
+ for await (const line of rl) {
225
+ lines.push(line);
226
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
227
+ }
228
+ return { lines, status: "complete", bytesRead };
229
+ } catch {
230
+ // Whatever WAS read before the failure is real content and may contain
231
+ // a real secret — discarding it because the file didn't finish cleanly
232
+ // would be a silent false negative, which is worse than an honest
233
+ // "partial" label.
234
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
235
+ } finally {
236
+ clearTimeout(timer);
237
+ rl.close();
238
+ stream.destroy();
239
+ }
240
+ }
241
+
242
+ module.exports = { id, label, available, files, readLines };