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