ctxpool 0.1.1 → 0.4.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 (58) hide show
  1. package/README.md +94 -103
  2. package/dist/cli.js +328 -184
  3. package/dist/cli.js.map +1 -1
  4. package/dist/clipboard.js +8 -10
  5. package/dist/clipboard.js.map +1 -1
  6. package/dist/format.d.ts +32 -0
  7. package/dist/format.js +110 -0
  8. package/dist/format.js.map +1 -0
  9. package/dist/handoff.d.ts +16 -13
  10. package/dist/handoff.js +441 -706
  11. package/dist/handoff.js.map +1 -1
  12. package/dist/mcp.js +99 -221
  13. package/dist/mcp.js.map +1 -1
  14. package/dist/pack.d.ts +14 -0
  15. package/dist/pack.js +121 -0
  16. package/dist/pack.js.map +1 -0
  17. package/dist/paths.d.ts +15 -0
  18. package/dist/paths.js +35 -5
  19. package/dist/paths.js.map +1 -1
  20. package/dist/redact.d.ts +6 -0
  21. package/dist/redact.js +85 -0
  22. package/dist/redact.js.map +1 -0
  23. package/dist/repo.d.ts +6 -1
  24. package/dist/repo.js +109 -123
  25. package/dist/repo.js.map +1 -1
  26. package/dist/session.d.ts +40 -1
  27. package/dist/session.js +1295 -302
  28. package/dist/session.js.map +1 -1
  29. package/dist/store.d.ts +13 -6
  30. package/dist/store.js +88 -115
  31. package/dist/store.js.map +1 -1
  32. package/dist/text.d.ts +6 -0
  33. package/dist/text.js +25 -0
  34. package/dist/text.js.map +1 -0
  35. package/dist/types.d.ts +44 -7
  36. package/dist/ui.d.ts +10 -1
  37. package/dist/ui.js +329 -383
  38. package/dist/ui.js.map +1 -1
  39. package/dist/util.d.ts +10 -1
  40. package/dist/util.js +40 -10
  41. package/dist/util.js.map +1 -1
  42. package/dist/verify.d.ts +16 -0
  43. package/dist/verify.js +74 -0
  44. package/dist/verify.js.map +1 -0
  45. package/dist/version.d.ts +1 -0
  46. package/dist/version.js +4 -0
  47. package/dist/version.js.map +1 -0
  48. package/dist-web/assets/contextpool-mark-4vSRiUeZ.svg +1 -0
  49. package/dist-web/assets/index-BBjy_S2c.css +2 -0
  50. package/dist-web/assets/index-QwGbBEu2.js +17 -0
  51. package/dist-web/index.html +4 -3
  52. package/package.json +18 -17
  53. package/dist/logs.d.ts +0 -9
  54. package/dist/logs.js +0 -82
  55. package/dist/logs.js.map +0 -1
  56. package/dist-web/assets/contextpool-mark-Bu_00i5t.svg +0 -16
  57. package/dist-web/assets/index-BfHRocKl.js +0 -14
  58. package/dist-web/assets/index-l1QjYqDb.css +0 -2
package/dist/session.js CHANGED
@@ -1,81 +1,600 @@
1
- import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
3
+ import { createRequire } from "node:module";
2
4
  import { homedir } from "node:os";
3
- import { basename, join, resolve } from "node:path";
4
- import { findRecentFiles, unique } from "./util.js";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { oneLine, truncate } from "./text.js";
8
+ import { CtxError, canonicalPath, findRecentFiles, unique } from "./util.js";
9
+ import { commandStatus } from "./verify.js";
10
+ const DEFAULT_EXCERPT_TOKENS = 3_000;
11
+ const AMBIGUITY_WINDOW_MS = 10 * 60 * 1000;
12
+ const MAX_CANDIDATES = 200;
13
+ export function codexHome() {
14
+ return process.env.CODEX_HOME || join(homedir(), ".codex");
15
+ }
16
+ export function claudeHome() {
17
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
18
+ }
19
+ /** Gemini CLI keeps its data in `.gemini` under GEMINI_CLI_HOME, or under the home directory. */
20
+ export function geminiHome() {
21
+ return join(process.env.GEMINI_CLI_HOME || homedir(), ".gemini");
22
+ }
23
+ /**
24
+ * Antigravity CLI (`agy`) always keeps its data in ~/.gemini/antigravity-cli. CTXPOOL_ANTIGRAVITY_DIR points
25
+ * ContextPool somewhere else; agy itself does not read it.
26
+ */
27
+ export function antigravityHome() {
28
+ return process.env.CTXPOOL_ANTIGRAVITY_DIR || join(homedir(), ".gemini", "antigravity-cli");
29
+ }
30
+ /** Every agent ContextPool can read, in the order `--from auto` lists them. */
31
+ export const SOURCE_AGENTS = ["codex", "claude", "gemini", "antigravity"];
32
+ const SOURCE_LABELS = { codex: "Codex", claude: "Claude", gemini: "Gemini", antigravity: "Antigravity" };
33
+ export function sourceLabel(source) {
34
+ return SOURCE_LABELS[source] || source;
35
+ }
36
+ /** Where each agent keeps the sessions ContextPool reads. */
37
+ export function sourceSessionDirs() {
38
+ return {
39
+ codex: join(codexHome(), "sessions"),
40
+ claude: join(claudeHome(), "projects"),
41
+ gemini: join(geminiHome(), "tmp"),
42
+ antigravity: join(antigravityHome(), "conversations")
43
+ };
44
+ }
5
45
  export function resolveSessionSource(options) {
6
46
  const from = normalizeFrom(options.from || "auto");
7
- const cwd = resolve(options.cwd);
8
- if (options.session) {
9
- return resolveExplicitSession(from, cwd, options.session, options.budget);
10
- }
11
- if (from === "codex")
12
- return parseCandidate(requiredBest(findCodexCandidates(cwd), "codex", cwd), options.budget);
13
- if (from === "claude")
14
- return parseCandidate(requiredBest(findClaudeCandidates(cwd), "claude", cwd), options.budget);
15
- const candidates = [...findCodexCandidates(cwd), ...findClaudeCandidates(cwd)]
16
- .sort(compareCandidates);
17
- if (candidates.length === 0) {
18
- throw new Error("No Codex or Claude session files were found. Pass --from and --session with a JSONL path.");
19
- }
20
- const [first, second] = candidates;
21
- if (first &&
22
- second &&
23
- first.matchedCwd &&
24
- second.matchedCwd &&
25
- first.source !== second.source &&
26
- Math.abs(Date.parse(first.updatedAt) - Date.parse(second.updatedAt)) < 10 * 60 * 1000) {
27
- throw new Error(`Both Codex and Claude have recent sessions for this workspace. Re-run with --from ${first.source} or --from ${second.source}.`);
47
+ const scope = toScope(options.cwd, options.repoRoot);
48
+ if (options.session)
49
+ return resolveExplicitSession(from, scope, options.session, options.budget);
50
+ const matched = listSessionCandidates({ ...scope, from }).filter((candidate) => candidate.matchedCwd);
51
+ const [first] = matched;
52
+ if (!first) {
53
+ const label = from === "auto" ? joinWords(SOURCE_AGENTS.map(sourceLabel), "or") : sourceLabel(from);
54
+ throw new CtxError(`No ${label} session found for ${scope.cwd}.`, "Run `ctxpool sessions --all` to list sessions, then pass --session <id or path>.");
55
+ }
56
+ if (from === "auto") {
57
+ const recent = matched.filter((candidate) => Date.parse(first.updatedAt) - Date.parse(candidate.updatedAt) < AMBIGUITY_WINDOW_MS);
58
+ const active = Array.from(new Set(recent.map((candidate) => candidate.source)));
59
+ if (active.length > 1) {
60
+ throw new CtxError(`${joinWords(active.map(sourceLabel), "and")} were ${active.length === 2 ? "both" : "all"} active in this workspace in the last 10 minutes.`, `Re-run with ${joinWords(active.map((source, index) => `--from ${source}${index === 0 ? " (most recent)" : ""}`), "or")}.`);
61
+ }
28
62
  }
29
63
  return parseCandidate(first, options.budget);
30
64
  }
65
+ /** Primary agent sessions, most relevant first. Subagent and reviewer threads are excluded. */
66
+ export function listSessionCandidates(options) {
67
+ const scope = toScope(options.cwd, options.repoRoot);
68
+ const from = options.from || "auto";
69
+ const sources = from === "auto" ? SOURCE_AGENTS : [from];
70
+ return sources.flatMap((source) => CANDIDATE_FINDERS[source](scope, options.includeSecondary === true)).sort(compareCandidates);
71
+ }
31
72
  function normalizeFrom(from) {
32
- if (from === "auto" || from === "codex" || from === "claude")
73
+ if (from === "auto" || SOURCE_AGENTS.includes(from))
33
74
  return from;
34
- throw new Error(`Unsupported --from value "${from}". Use auto, codex, or claude.`);
75
+ throw new CtxError(`Unsupported --from value "${from}".`, `Use ${joinWords(["auto", ...SOURCE_AGENTS], "or")}.`);
76
+ }
77
+ function resolveExplicitSession(from, scope, session, budget) {
78
+ const asPath = resolve(session);
79
+ if (existsSync(asPath) && statSync(asPath).isFile()) {
80
+ const source = from === "auto" ? detectSource(asPath) : from;
81
+ const parsed = SESSION_PARSERS[source](asPath, budget);
82
+ parsed.matchedCwd = matchesWorkspace(parsed.cwd, scope);
83
+ return parsed;
84
+ }
85
+ const match = listSessionCandidates({ ...scope, from, includeSecondary: true }).find((candidate) => candidate.id === session || candidate.id.startsWith(session) || basename(candidate.path).includes(session));
86
+ if (!match)
87
+ throw new CtxError(`No session matched "${session}".`, "Pass an id from `ctxpool sessions --all` or a path to a session file.");
88
+ return parseCandidate(match, budget);
89
+ }
90
+ function parseCandidate(candidate, budget) {
91
+ const session = SESSION_PARSERS[candidate.source](candidate.path, budget);
92
+ session.id = candidate.id || session.id;
93
+ session.cwd = session.cwd || candidate.cwd;
94
+ session.updatedAt = candidate.updatedAt;
95
+ session.matchedCwd = candidate.matchedCwd;
96
+ session.threadName = session.threadName || candidate.title;
97
+ return session;
98
+ }
99
+ function compareCandidates(a, b) {
100
+ if (a.matchedCwd !== b.matchedCwd)
101
+ return a.matchedCwd ? -1 : 1;
102
+ return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
103
+ }
104
+ /** "a", "a or b", "a, b, or c". */
105
+ function joinWords(words, conjunction) {
106
+ if (words.length <= 2)
107
+ return words.join(` ${conjunction} `);
108
+ return `${words.slice(0, -1).join(", ")}, ${conjunction} ${words.at(-1)}`;
109
+ }
110
+ const CANDIDATE_FINDERS = {
111
+ codex: findCodexCandidates,
112
+ claude: findClaudeCandidates,
113
+ gemini: findGeminiCandidates,
114
+ antigravity: findAntigravityCandidates
115
+ };
116
+ const SESSION_PARSERS = {
117
+ codex: parseCodexSession,
118
+ claude: parseClaudeSession,
119
+ gemini: parseGeminiSession,
120
+ antigravity: parseAntigravitySession
121
+ };
122
+ // ---------------------------------------------------------------------------
123
+ // Discovery
124
+ function findCodexCandidates(scope, includeSecondary) {
125
+ const index = readCodexIndex();
126
+ const candidates = [];
127
+ for (const path of findRecentFiles(join(codexHome(), "sessions"), MAX_CANDIDATES, /\.jsonl$/i)) {
128
+ const meta = readCodexMeta(path);
129
+ if (meta.secondary && !includeSecondary)
130
+ continue;
131
+ const id = meta.id || sessionIdFromPath(path);
132
+ candidates.push({
133
+ source: "codex",
134
+ path,
135
+ id,
136
+ cwd: meta.cwd,
137
+ updatedAt: mtimeIso(path),
138
+ matchedCwd: matchesWorkspace(meta.cwd, scope),
139
+ title: index.get(id)
140
+ });
141
+ }
142
+ return candidates;
143
+ }
144
+ function findClaudeCandidates(scope, includeSecondary) {
145
+ const projects = join(claudeHome(), "projects");
146
+ const preferred = unique([scope.cwd, scope.repoRoot || ""].filter(Boolean).map((path) => join(projects, encodeClaudeProjectPath(path)))).filter(existsSync);
147
+ const roots = preferred.length ? preferred : [projects];
148
+ const skipDirs = includeSecondary ? [] : ["subagents"];
149
+ const candidates = [];
150
+ for (const path of roots.flatMap((root) => findRecentFiles(root, MAX_CANDIDATES, /\.jsonl$/i, skipDirs))) {
151
+ const meta = readClaudeMeta(path);
152
+ if (!meta.cwd && !meta.id)
153
+ continue;
154
+ candidates.push({
155
+ source: "claude",
156
+ path,
157
+ id: meta.id || sessionIdFromPath(path),
158
+ cwd: meta.cwd,
159
+ updatedAt: mtimeIso(path),
160
+ matchedCwd: matchesWorkspace(meta.cwd, scope),
161
+ title: meta.title
162
+ });
163
+ }
164
+ return candidates;
165
+ }
166
+ /**
167
+ * Gemini CLI writes one chat per session to tmp/<project>/chats/session-<time>-<id>.jsonl (a single .json file in
168
+ * older builds). Subagent chats sit in a folder named after the parent session.
169
+ */
170
+ function findGeminiCandidates(scope, includeSecondary) {
171
+ const tmp = join(geminiHome(), "tmp");
172
+ const registry = readGeminiProjectRegistry();
173
+ const byId = new Map();
174
+ for (const project of listDirectories(tmp)) {
175
+ const chats = join(tmp, project, "chats");
176
+ const projectRoot = readGeminiProjectRoot(join(tmp, project), registry);
177
+ for (const path of findRecentFiles(chats, MAX_CANDIDATES, /\.jsonl?$/i)) {
178
+ const meta = readGeminiMeta(path);
179
+ if (!meta.id)
180
+ continue;
181
+ if ((meta.subagent || dirname(path) !== chats) && !includeSecondary)
182
+ continue;
183
+ const cwd = projectRoot || geminiRootFromHash(meta.projectHash, scope);
184
+ const candidate = {
185
+ source: "gemini",
186
+ path,
187
+ id: meta.id,
188
+ cwd,
189
+ updatedAt: mtimeIso(path),
190
+ matchedCwd: matchesWorkspace(cwd, scope),
191
+ title: meta.title
192
+ };
193
+ // Resuming an old .json chat copies it to a .jsonl file beside the original; keep the newer copy.
194
+ const existing = byId.get(meta.id);
195
+ if (!existing || Date.parse(candidate.updatedAt) > Date.parse(existing.updatedAt))
196
+ byId.set(meta.id, candidate);
197
+ }
198
+ }
199
+ return [...byId.values()];
200
+ }
201
+ function readGeminiMeta(path) {
202
+ let metadata = {};
203
+ let firstPrompt = "";
204
+ if (/\.json$/i.test(path)) {
205
+ const conversation = readGeminiConversation(path);
206
+ metadata = conversation.metadata;
207
+ firstPrompt = conversation.messages.map(geminiUserPrompt).find(Boolean) || "";
208
+ }
209
+ else {
210
+ for (const line of readHead(path, 128_000).split(/\r?\n/).slice(0, 60)) {
211
+ const record = objectValue(parseJson(line));
212
+ if (typeof record.sessionId === "string" && typeof record.projectHash === "string")
213
+ metadata = { ...metadata, ...record };
214
+ else if (!firstPrompt && record.type === "user")
215
+ firstPrompt = geminiUserPrompt(record);
216
+ }
217
+ for (const line of readTail(path, 96_000).split(/\r?\n/)) {
218
+ if (!line.includes('"summary"'))
219
+ continue;
220
+ const summary = stringValue(objectValue(objectValue(parseJson(line)).$set).summary);
221
+ if (summary)
222
+ metadata.summary = summary;
223
+ }
224
+ }
225
+ return {
226
+ id: stringValue(metadata.sessionId) || undefined,
227
+ projectHash: stringValue(metadata.projectHash) || undefined,
228
+ subagent: metadata.kind === "subagent",
229
+ title: stringValue(metadata.summary) || (firstPrompt ? truncate(oneLine(firstPrompt), 80) : undefined)
230
+ };
231
+ }
232
+ /** projects.json maps each project root to the short folder name Gemini uses under tmp/. */
233
+ function readGeminiProjectRegistry() {
234
+ const map = new Map();
235
+ const path = join(geminiHome(), "projects.json");
236
+ if (!existsSync(path))
237
+ return map;
238
+ for (const [root, name] of Object.entries(objectValue(objectValue(parseJson(readFileSync(path, "utf8"))).projects))) {
239
+ if (typeof name === "string")
240
+ map.set(name, root);
241
+ }
242
+ return map;
243
+ }
244
+ function readGeminiProjectRoot(projectDir, registry = readGeminiProjectRegistry()) {
245
+ const marker = join(projectDir, ".project_root");
246
+ const root = existsSync(marker) ? readFileSync(marker, "utf8").trim() : "";
247
+ return root || registry.get(basename(projectDir));
248
+ }
249
+ /** Older Gemini builds name project folders by the SHA-256 of the project root and leave no marker file. */
250
+ function geminiRootFromHash(projectHash, scope) {
251
+ if (!projectHash)
252
+ return undefined;
253
+ return [scope.cwd, scope.repoRoot].find((root) => root && createHash("sha256").update(root).digest("hex") === projectHash);
254
+ }
255
+ /** The tmp/<project> folder that holds a chat file, for main and subagent chats alike. */
256
+ function geminiProjectDir(path) {
257
+ let dir = dirname(path);
258
+ for (let depth = 0; depth < 2; depth++, dir = dirname(dir))
259
+ if (basename(dir) === "chats")
260
+ return dirname(dir);
261
+ return undefined;
262
+ }
263
+ /**
264
+ * Antigravity CLI stores each conversation as a SQLite database, conversations/<id>.db, and indexes them in
265
+ * conversation_summaries.db. Subagent conversations name their parent there and in their own metadata.
266
+ */
267
+ function findAntigravityCandidates(scope, includeSecondary) {
268
+ const index = readAntigravityIndex();
269
+ const candidates = [];
270
+ for (const path of findRecentFiles(join(antigravityHome(), "conversations"), MAX_CANDIDATES, /\.db$/i)) {
271
+ const id = antigravityId(path);
272
+ const summary = index.get(id) || readAntigravityMetadata(path);
273
+ if (summary.parentId && !includeSecondary)
274
+ continue;
275
+ candidates.push({
276
+ source: "antigravity",
277
+ path,
278
+ id,
279
+ cwd: summary.cwd,
280
+ updatedAt: antigravityUpdatedAt(path),
281
+ matchedCwd: matchesWorkspace(summary.cwd, scope),
282
+ title: summary.title
283
+ });
284
+ }
285
+ return candidates;
286
+ }
287
+ function readAntigravityIndex() {
288
+ const index = new Map();
289
+ const db = openAntigravityDb(join(antigravityHome(), "conversation_summaries.db"));
290
+ if (!db)
291
+ return index;
292
+ try {
293
+ const rows = db.prepare("SELECT conversation_id, title, workspace_uris, parent_conversation_id FROM conversation_summaries").all();
294
+ for (const row of rows) {
295
+ const workspaces = parseJson(String(row.workspace_uris ?? ""));
296
+ index.set(String(row.conversation_id), {
297
+ cwd: fileUriToPath(Array.isArray(workspaces) ? stringValue(workspaces[0]) : ""),
298
+ title: String(row.title ?? "") || undefined,
299
+ parentId: String(row.parent_conversation_id ?? "") || undefined
300
+ });
301
+ }
302
+ }
303
+ catch {
304
+ // An older or newer index layout; each conversation's own metadata still names its workspace.
305
+ }
306
+ finally {
307
+ db.close();
308
+ }
309
+ return index;
310
+ }
311
+ /** For conversations missing from the index: the workspace from their own metadata, titled by the first prompt still on record. */
312
+ function readAntigravityMetadata(path) {
313
+ const db = openAntigravityDb(path);
314
+ if (!db)
315
+ return {};
316
+ try {
317
+ const row = db.prepare("SELECT step_payload FROM steps WHERE step_type = ? AND status != ? ORDER BY idx LIMIT 1").get(AGY_STEP.userInput, AGY_STATUS.cleared);
318
+ const prompt = row?.step_payload instanceof Uint8Array ? antigravityPrompt(pbMessage(pbDecode(row.step_payload), 19)) : "";
319
+ return { ...antigravityTrajectoryMetadata(db), title: prompt ? truncate(oneLine(cleanText(prompt)), 80) : undefined };
320
+ }
321
+ catch {
322
+ return antigravityTrajectoryMetadata(db);
323
+ }
324
+ finally {
325
+ db.close();
326
+ }
327
+ }
328
+ /** The CortexTrajectoryMetadata proto: workspaces[].workspace_folder_absolute_uri (1.1), workspace_uris (7), parent_conversation_id (5). */
329
+ function antigravityTrajectoryMetadata(db) {
330
+ try {
331
+ const row = db.prepare("SELECT data FROM trajectory_metadata_blob WHERE id = 'main'").get();
332
+ const metadata = pbDecode(row?.data instanceof Uint8Array ? row.data : undefined);
333
+ const uri = pbString(pbMessage(metadata, 1), 1) || pbString(metadata, 7);
334
+ return { cwd: fileUriToPath(uri), parentId: pbString(metadata, 5) || undefined };
335
+ }
336
+ catch {
337
+ return {};
338
+ }
339
+ }
340
+ /** agy writes recent steps to the -wal file first, so it can be newer than the database. An empty one holds nothing new. */
341
+ function antigravityUpdatedAt(path) {
342
+ const database = statSync(path).mtime;
343
+ const wal = existsSync(`${path}-wal`) ? statSync(`${path}-wal`) : undefined;
344
+ return (wal?.size && wal.mtime > database ? wal.mtime : database).toISOString();
345
+ }
346
+ function antigravityId(path) {
347
+ return basename(path).replace(/\.db$/i, "");
348
+ }
349
+ function fileUriToPath(uri) {
350
+ if (!uri)
351
+ return undefined;
352
+ try {
353
+ return uri.startsWith("file:") ? fileURLToPath(uri) : uri;
354
+ }
355
+ catch {
356
+ return undefined;
357
+ }
358
+ }
359
+ function readCodexMeta(path) {
360
+ const record = objectValue(parseJson(readFirstLine(path)));
361
+ if (stringValue(record.type) !== "session_meta")
362
+ return { secondary: false };
363
+ const payload = objectValue(record.payload);
364
+ const threadSource = stringValue(payload.thread_source);
365
+ const source = payload.source;
366
+ const secondary = threadSource === "subagent" || threadSource === "guardian_review" || (typeof source === "object" && source !== null && "subagent" in source);
367
+ return { id: stringValue(payload.id) || undefined, cwd: stringValue(payload.cwd) || undefined, secondary };
35
368
  }
36
- export function parseCodexSession(path, budget = 12_000) {
369
+ function readClaudeMeta(path) {
370
+ let id;
371
+ let cwd;
372
+ let firstPrompt;
373
+ for (const line of readHead(path, 128_000).split(/\r?\n/).slice(0, 60)) {
374
+ const record = objectValue(parseJson(line));
375
+ id ||= stringValue(record.sessionId) || undefined;
376
+ cwd ||= stringValue(record.cwd) || undefined;
377
+ if (!firstPrompt && record.type === "user" && !record.isMeta && !record.isSidechain) {
378
+ const content = objectValue(record.message).content;
379
+ if (typeof content === "string")
380
+ firstPrompt = cleanText(parseClaudeUserText(content).prompt) || undefined;
381
+ }
382
+ if (id && cwd && firstPrompt)
383
+ break;
384
+ }
385
+ let title;
386
+ for (const line of readTail(path, 96_000).split(/\r?\n/)) {
387
+ if (!line.includes("title") && !line.includes("summary"))
388
+ continue;
389
+ const record = objectValue(parseJson(line));
390
+ title = claudeTitle(record) || title;
391
+ }
392
+ return { id, cwd, title: title || (firstPrompt ? truncate(oneLine(firstPrompt), 80) : undefined) };
393
+ }
394
+ function readCodexIndex() {
395
+ const map = new Map();
396
+ const indexPath = join(codexHome(), "session_index.jsonl");
397
+ if (!existsSync(indexPath))
398
+ return map;
399
+ for (const line of readFileSync(indexPath, "utf8").split(/\r?\n/)) {
400
+ const record = objectValue(parseJson(line));
401
+ const id = stringValue(record.id);
402
+ const name = stringValue(record.thread_name);
403
+ if (id && name)
404
+ map.set(id, name);
405
+ }
406
+ return map;
407
+ }
408
+ function detectSource(path) {
409
+ // Only Gemini writes a whole session as one JSON document, and only Antigravity keeps one in SQLite.
410
+ if (/\.json$/i.test(path))
411
+ return "gemini";
412
+ if (/\.db$/i.test(path))
413
+ return "antigravity";
414
+ for (const line of readHead(path, 64_000).split(/\r?\n/).slice(0, 10)) {
415
+ const record = objectValue(parseJson(line));
416
+ if (record.type === "session_meta" || "payload" in record)
417
+ return "codex";
418
+ if ("projectHash" in record || "$set" in record || "$rewindTo" in record)
419
+ return "gemini";
420
+ if ("sessionId" in record || "message" in record)
421
+ return "claude";
422
+ }
423
+ if (path.includes(join(".gemini", "tmp")))
424
+ return "gemini";
425
+ return path.includes(`${join(".claude", "projects")}`) ? "claude" : "codex";
426
+ }
427
+ function listDirectories(root) {
428
+ try {
429
+ return readdirSync(root, { withFileTypes: true })
430
+ .filter((entry) => entry.isDirectory())
431
+ .map((entry) => entry.name);
432
+ }
433
+ catch {
434
+ return [];
435
+ }
436
+ }
437
+ // ---------------------------------------------------------------------------
438
+ // Parsing
439
+ class SessionBuilder {
440
+ transcript = [];
441
+ commands = [];
442
+ edits = [];
443
+ plan;
444
+ planText;
445
+ compactSummary;
446
+ links = [];
447
+ seq = 0;
448
+ pendingCommands = new Map();
449
+ message(role, text, timestamp) {
450
+ const clean = cleanText(text);
451
+ if (!isUsefulText(clean))
452
+ return;
453
+ const last = this.transcript.at(-1);
454
+ if (last && last.role === role && last.text === clean)
455
+ return;
456
+ this.seq++;
457
+ this.transcript.push({ role, text: clean, timestamp, kind: "message" });
458
+ }
459
+ toolCall(name, detail, timestamp) {
460
+ this.seq++;
461
+ this.transcript.push({ role: "tool", text: detail ? `${name}: ${truncate(oneLine(detail), 240)}` : name, timestamp, kind: "tool_call" });
462
+ }
463
+ /**
464
+ * Record a command whose result may arrive later under `callId`. `assumeSuccess` marks logs where a
465
+ * result without an error flag means exit 0 (Claude); elsewhere a missing exit code stays unknown.
466
+ */
467
+ commandCall(callId, command, timestamp, assumeSuccess = false) {
468
+ if (!command)
469
+ return;
470
+ this.toolCall("Shell", command, timestamp);
471
+ this.recordCommand(command, undefined, "");
472
+ if (callId)
473
+ this.pendingCommands.set(callId, { index: this.commands.length - 1, assumeSuccess });
474
+ }
475
+ commandResult(callId, output, options = {}) {
476
+ const pending = this.pendingCommands.get(callId);
477
+ if (!pending)
478
+ return;
479
+ this.pendingCommands.delete(callId);
480
+ const exitCode = options.exitCode ?? parseExitCode(output) ?? (options.isError ? 1 : pending.assumeSuccess ? 0 : undefined);
481
+ const run = { ...this.commands[pending.index], ...commandOutcome(this.commands[pending.index].command, exitCode, output) };
482
+ this.commands[pending.index] = run;
483
+ if (run.status === "failed")
484
+ this.pushFailure(run);
485
+ }
486
+ /** A command whose call and result are logged together. */
487
+ command(command, exitCode, output, timestamp) {
488
+ if (!command)
489
+ return;
490
+ this.toolCall("Shell", command, timestamp);
491
+ this.recordCommand(command, exitCode, output);
492
+ }
493
+ edit(path, timestamp) {
494
+ if (!path)
495
+ return;
496
+ this.seq++;
497
+ this.edits.push({ path, seq: this.seq });
498
+ this.transcript.push({ role: "tool", text: `Edit: ${path}`, timestamp, kind: "edit" });
499
+ }
500
+ setPlan(items) {
501
+ if (items.length)
502
+ this.plan = items;
503
+ }
504
+ updatePlan(update) {
505
+ if (!this.plan)
506
+ return;
507
+ const plan = this.plan.map((item) => ({ ...item }));
508
+ if (update.advance) {
509
+ const current = plan.findIndex((item) => item.status === "in_progress");
510
+ if (current >= 0)
511
+ plan[current].status = "completed";
512
+ const next = plan.findIndex((item, index) => index > current && item.status === "pending");
513
+ if (next >= 0)
514
+ plan[next].status = "in_progress";
515
+ }
516
+ for (const index of update.done || [])
517
+ if (plan[index - 1])
518
+ plan[index - 1].status = "completed";
519
+ if (update.start && plan[update.start - 1])
520
+ plan[update.start - 1].status = "in_progress";
521
+ this.plan = plan;
522
+ }
523
+ recordCommand(command, exitCode, output) {
524
+ this.seq++;
525
+ const run = { command: truncate(oneLine(command), 400), ...commandOutcome(command, exitCode, output), seq: this.seq };
526
+ this.commands.push(run);
527
+ if (run.status === "failed")
528
+ this.pushFailure(run);
529
+ }
530
+ pushFailure(run) {
531
+ const label = run.exitCode ? `Exit ${run.exitCode}` : "Failed";
532
+ this.transcript.push({ role: "tool", text: `${label}${run.errorLine ? `: ${run.errorLine}` : ""}`, kind: "tool_error" });
533
+ }
534
+ }
535
+ function commandOutcome(command, exitCode, output) {
536
+ const status = commandStatus(command, exitCode, output);
537
+ return { status, exitCode, errorLine: status === "failed" ? firstErrorLine(output) : undefined };
538
+ }
539
+ export function parseCodexSession(path, budget = DEFAULT_EXCERPT_TOKENS) {
37
540
  const lines = readSessionLines(path);
38
- const transcript = [];
541
+ const builder = new SessionBuilder();
39
542
  let id = sessionIdFromPath(path);
40
543
  let cwd;
41
- let updatedAt = statSync(path).mtime.toISOString();
544
+ // Newer Codex logs record typed items; older ones mirror messages across event_msg and response_item.
545
+ const mode = lines.some((line) => line.includes('"item_completed"'))
546
+ ? "items"
547
+ : lines.some((line) => line.includes('"user_message"'))
548
+ ? "events"
549
+ : "responses";
42
550
  for (const line of lines) {
43
- const item = parseJson(line);
44
- if (!item || typeof item !== "object")
45
- continue;
46
- const record = item;
551
+ const record = objectValue(parseJson(line));
47
552
  const type = stringValue(record.type);
553
+ const payload = objectValue(record.payload);
554
+ const timestamp = stringValue(record.timestamp) || stringValue(payload.timestamp) || undefined;
48
555
  if (type === "session_meta") {
49
- const payload = objectValue(record.payload);
50
556
  id = stringValue(payload.id) || id;
51
557
  cwd = stringValue(payload.cwd) || cwd;
52
- updatedAt = stringValue(payload.timestamp) || updatedAt;
53
558
  continue;
54
559
  }
55
- const timestamp = stringValue(record.timestamp) || stringValue(objectValue(record.payload).timestamp);
56
- if (type === "event_msg") {
57
- const payload = objectValue(record.payload);
58
- const payloadType = stringValue(payload.type);
59
- if (payloadType === "user_message")
60
- pushEntry(transcript, "user", stringValue(payload.message), timestamp, "message");
61
- if (payloadType === "agent_message")
62
- pushEntry(transcript, "assistant", stringValue(payload.message), timestamp, "message");
560
+ if (type === "compacted") {
561
+ const message = stringValue(payload.message);
562
+ if (message.trim())
563
+ builder.compactSummary = cleanText(message);
63
564
  continue;
64
565
  }
65
- if (type === "response_item") {
66
- const payload = objectValue(record.payload);
67
- const itemType = stringValue(payload.type);
68
- if (itemType === "message") {
69
- const role = stringValue(payload.role) === "user" ? "user" : "assistant";
70
- pushEntry(transcript, role, collectText(payload.content).join("\n"), timestamp, "message");
566
+ if (type === "event_msg") {
567
+ const eventType = stringValue(payload.type);
568
+ if (mode === "items" && eventType === "item_completed") {
569
+ readCodexItem(builder, objectValue(payload.item), timestamp);
71
570
  }
72
- else if (itemType === "function_call") {
73
- const name = stringValue(payload.name) || "tool_call";
74
- pushEntry(transcript, "tool", `${name} ${stringValue(payload.arguments)}`.trim(), timestamp, "tool_call");
571
+ else if (mode === "events" && eventType === "user_message") {
572
+ builder.message("user", stringValue(payload.message), timestamp);
75
573
  }
76
- else if (itemType === "function_call_output") {
77
- pushEntry(transcript, "tool", stringValue(payload.output), timestamp, "tool_output");
574
+ else if (mode === "events" && eventType === "agent_message") {
575
+ builder.message("assistant", stringValue(payload.message), timestamp);
78
576
  }
577
+ else if (mode !== "items" && eventType === "exec_command_end") {
578
+ builder.commandResult(stringValue(payload.call_id), outputText(payload.aggregated_output ?? payload.stdout ?? payload.formatted_output), {
579
+ exitCode: numberValue(payload.exit_code)
580
+ });
581
+ }
582
+ continue;
583
+ }
584
+ if (type !== "response_item")
585
+ continue;
586
+ const itemType = stringValue(payload.type);
587
+ if (itemType === "message" && mode === "responses") {
588
+ const role = stringValue(payload.role);
589
+ if (role === "user" || role === "assistant")
590
+ builder.message(role, collectText(payload.content).join("\n"), timestamp);
591
+ }
592
+ else if (itemType === "function_call" || itemType === "custom_tool_call" || itemType === "local_shell_call") {
593
+ readCodexCall(builder, payload, mode, timestamp);
594
+ }
595
+ else if ((itemType === "function_call_output" || itemType === "custom_tool_call_output") && mode !== "items") {
596
+ const output = outputText(payload.output);
597
+ builder.commandResult(stringValue(payload.call_id), output, { exitCode: parseExitCode(output) });
79
598
  }
80
599
  }
81
600
  return buildSession({
@@ -83,39 +602,133 @@ export function parseCodexSession(path, budget = 12_000) {
83
602
  id,
84
603
  path,
85
604
  cwd,
86
- updatedAt,
87
- matchedCwd: false,
88
605
  lineCount: lines.length,
89
- transcript,
90
- budget
606
+ builder,
607
+ budget,
608
+ threadName: undefined
91
609
  });
92
610
  }
93
- export function parseClaudeSession(path, budget = 12_000) {
611
+ function readCodexItem(builder, item, timestamp) {
612
+ switch (stringValue(item.type)) {
613
+ case "UserMessage":
614
+ builder.message("user", collectText(item.content).join("\n"), timestamp);
615
+ return;
616
+ case "AgentMessage":
617
+ builder.message("assistant", collectText(item.content).join("\n"), timestamp);
618
+ return;
619
+ case "CommandExecution": {
620
+ const command = Array.isArray(item.command) ? shellCommand(item.command) : stringValue(item.command);
621
+ builder.command(command, numberValue(item.exit_code) ?? (stringValue(item.status) === "failed" ? 1 : undefined), outputText(item.aggregated_output), timestamp);
622
+ return;
623
+ }
624
+ case "FileChange":
625
+ for (const path of Object.keys(objectValue(item.changes)))
626
+ builder.edit(path, timestamp);
627
+ return;
628
+ case "McpToolCall":
629
+ builder.toolCall(`${stringValue(item.server)}.${stringValue(item.tool)}`, stringValue(item.status) === "failed" ? "failed" : "", timestamp);
630
+ return;
631
+ case "TodoList": {
632
+ const items = Array.isArray(item.items) ? item.items.map(objectValue) : [];
633
+ builder.setPlan(items
634
+ .map((entry) => ({ text: oneLine(stringValue(entry.text)), status: entry.completed === true ? "completed" : "pending" }))
635
+ .filter((entry) => entry.text));
636
+ return;
637
+ }
638
+ }
639
+ }
640
+ function readCodexCall(builder, payload, mode, timestamp) {
641
+ const name = stringValue(payload.name);
642
+ const callId = stringValue(payload.call_id);
643
+ const rawArgs = stringValue(payload.arguments) || stringValue(payload.input);
644
+ const args = objectValue(parseJson(rawArgs));
645
+ if (name === "update_plan") {
646
+ builder.setPlan(planItemsFrom(args.plan));
647
+ return;
648
+ }
649
+ // Items mode records commands and edits as typed items; skip the raw calls to avoid double counting.
650
+ if (mode === "items")
651
+ return;
652
+ if (name === "apply_patch") {
653
+ for (const path of patchFiles(rawArgs))
654
+ builder.edit(path, timestamp);
655
+ return;
656
+ }
657
+ if (stringValue(payload.type) === "local_shell_call") {
658
+ const action = objectValue(payload.action);
659
+ builder.command(Array.isArray(action.command) ? shellCommand(action.command) : "", undefined, "", timestamp);
660
+ return;
661
+ }
662
+ if (name === "shell" || name === "container.exec" || name === "local_shell" || name === "exec_command") {
663
+ const command = Array.isArray(args.command) ? shellCommand(args.command) : stringValue(args.cmd) || stringValue(args.command);
664
+ if (command.includes("apply_patch")) {
665
+ for (const path of patchFiles(command))
666
+ builder.edit(path, timestamp);
667
+ return;
668
+ }
669
+ builder.commandCall(callId, command, timestamp);
670
+ return;
671
+ }
672
+ if (name)
673
+ builder.toolCall(name, "", timestamp);
674
+ }
675
+ export function parseClaudeSession(path, budget = DEFAULT_EXCERPT_TOKENS) {
94
676
  const lines = readSessionLines(path);
95
- const transcript = [];
677
+ const builder = new SessionBuilder();
96
678
  let id = sessionIdFromPath(path);
97
679
  let cwd;
98
- let updatedAt = statSync(path).mtime.toISOString();
680
+ let title;
99
681
  for (const line of lines) {
100
- const item = parseJson(line);
101
- if (!item || typeof item !== "object")
682
+ const record = objectValue(parseJson(line));
683
+ const type = stringValue(record.type);
684
+ const recordTitle = claudeTitle(record);
685
+ if (recordTitle) {
686
+ title = recordTitle;
102
687
  continue;
103
- const record = item;
104
- if (record.isMeta === true)
688
+ }
689
+ if (type === "pr-link" && stringValue(record.prUrl)) {
690
+ builder.links.push(stringValue(record.prUrl));
691
+ continue;
692
+ }
693
+ if (type !== "user" && type !== "assistant")
694
+ continue;
695
+ if (record.isMeta === true || record.isSidechain === true)
105
696
  continue;
106
697
  id = stringValue(record.sessionId) || id;
107
698
  cwd = stringValue(record.cwd) || cwd;
108
- const timestamp = stringValue(record.timestamp);
109
- updatedAt = timestamp || updatedAt;
110
- const type = stringValue(record.type);
111
- const message = objectValue(record.message);
112
- if (message.role || message.content) {
113
- const role = stringValue(message.role) === "assistant" ? "assistant" : stringValue(message.role) === "user" ? "user" : "system";
114
- pushEntry(transcript, role, collectText(message.content).join("\n"), timestamp, "message");
699
+ const timestamp = stringValue(record.timestamp) || undefined;
700
+ const content = objectValue(record.message).content;
701
+ if (record.isCompactSummary === true) {
702
+ builder.compactSummary = cleanText(collectText(content).join("\n"));
703
+ continue;
704
+ }
705
+ if (type === "user") {
706
+ if (typeof content === "string") {
707
+ const parsed = parseClaudeUserText(content);
708
+ if (parsed.prompt)
709
+ builder.message("user", parsed.prompt, timestamp);
710
+ continue;
711
+ }
712
+ const texts = [];
713
+ for (const block of Array.isArray(content) ? content.map(objectValue) : []) {
714
+ if (block.type === "text") {
715
+ const parsed = parseClaudeUserText(stringValue(block.text));
716
+ if (parsed.prompt)
717
+ texts.push(parsed.prompt);
718
+ }
719
+ else if (block.type === "tool_result") {
720
+ builder.commandResult(stringValue(block.tool_use_id), collectText(block.content).join("\n"), { isError: block.is_error === true });
721
+ }
722
+ }
723
+ if (texts.length)
724
+ builder.message("user", texts.join("\n"), timestamp);
115
725
  continue;
116
726
  }
117
- if (type === "system" || stringValue(record.subtype) === "local_command") {
118
- pushEntry(transcript, "tool", collectText(record.content).join("\n"), timestamp, stringValue(record.subtype) || "system");
727
+ for (const block of Array.isArray(content) ? content.map(objectValue) : []) {
728
+ if (block.type === "text")
729
+ builder.message("assistant", stringValue(block.text), timestamp);
730
+ else if (block.type === "tool_use")
731
+ readClaudeTool(builder, stringValue(block.name), objectValue(block.input), stringValue(block.id), timestamp);
119
732
  }
120
733
  }
121
734
  return buildSession({
@@ -123,208 +736,498 @@ export function parseClaudeSession(path, budget = 12_000) {
123
736
  id,
124
737
  path,
125
738
  cwd,
126
- updatedAt,
127
- matchedCwd: false,
128
739
  lineCount: lines.length,
129
- transcript,
740
+ builder,
130
741
  budget,
742
+ threadName: title,
131
743
  supplementalPaths: latestClaudeSupplementalPaths()
132
744
  });
133
745
  }
134
- function resolveExplicitSession(from, cwd, session, budget) {
135
- if (existsSync(session)) {
136
- const source = from === "auto" ? inferSourceFromPath(session) : from;
137
- if (source === "claude")
138
- return parseWithMatch(parseClaudeSession(session, budget), cwd);
139
- return parseWithMatch(parseCodexSession(session, budget), cwd);
746
+ function readClaudeTool(builder, name, input, callId, timestamp) {
747
+ if (name === "Bash" || name === "PowerShell") {
748
+ builder.commandCall(callId, stringValue(input.command), timestamp, input.run_in_background !== true);
749
+ return;
140
750
  }
141
- if (from === "claude") {
142
- throw new Error("--session for Claude must be a JSONL path.");
751
+ if (name === "Edit" || name === "Write" || name === "MultiEdit" || name === "NotebookEdit") {
752
+ builder.edit(stringValue(input.file_path) || stringValue(input.notebook_path), timestamp);
753
+ return;
143
754
  }
144
- const match = findRecentFiles(join(homedir(), ".codex", "sessions"), 500)
145
- .find((path) => path.includes(session));
146
- if (!match)
147
- throw new Error(`No Codex session file matched "${session}". Pass a JSONL path instead.`);
148
- return parseWithMatch(parseCodexSession(match, budget), cwd);
149
- }
150
- function findCodexCandidates(cwd) {
151
- const paths = findRecentFiles(join(homedir(), ".codex", "sessions"), 300)
152
- .filter((path) => path.endsWith(".jsonl"));
153
- const index = readCodexIndex();
154
- return paths.map((path) => {
155
- const meta = readCodexMeta(path);
156
- const id = meta.id || sessionIdFromPath(path);
157
- return {
158
- source: "codex",
159
- path,
160
- id,
161
- cwd: meta.cwd,
162
- updatedAt: meta.updatedAt || statSync(path).mtime.toISOString(),
163
- matchedCwd: samePath(meta.cwd, cwd),
164
- threadName: index.get(id)
165
- };
166
- }).sort(compareCandidates);
167
- }
168
- function findClaudeCandidates(cwd) {
169
- const encoded = encodeClaudeProjectPath(cwd);
170
- const preferred = join(homedir(), ".claude", "projects", encoded);
171
- const roots = existsSync(preferred) ? [preferred] : [join(homedir(), ".claude", "projects")];
172
- return roots
173
- .flatMap((root) => findRecentFiles(root, 300))
174
- .filter((path) => path.endsWith(".jsonl"))
175
- .map((path) => {
176
- const meta = readClaudeMeta(path);
177
- return {
178
- source: "claude",
179
- path,
180
- id: meta.id || sessionIdFromPath(path),
181
- cwd: meta.cwd,
182
- updatedAt: meta.updatedAt || statSync(path).mtime.toISOString(),
183
- matchedCwd: samePath(meta.cwd, cwd)
184
- };
185
- })
186
- .sort(compareCandidates);
755
+ if (name === "ExitPlanMode") {
756
+ if (stringValue(input.plan))
757
+ builder.planText = cleanText(stringValue(input.plan));
758
+ return;
759
+ }
760
+ const planItems = planItemsFrom(input.todos ?? input.items ?? input.plan);
761
+ if (planItems.length) {
762
+ builder.setPlan(planItems);
763
+ return;
764
+ }
765
+ if (/update_plan$/.test(name)) {
766
+ builder.updatePlan({
767
+ advance: input.advance === true,
768
+ done: Array.isArray(input.done) ? input.done.filter((value) => typeof value === "number") : undefined,
769
+ start: numberValue(input.start)
770
+ });
771
+ return;
772
+ }
773
+ const detail = stringValue(input.file_path) || stringValue(input.pattern) || stringValue(input.url) || stringValue(input.query) || stringValue(input.description);
774
+ builder.toolCall(name, detail, timestamp);
187
775
  }
188
- function parseCandidate(candidate, budget) {
189
- const session = candidate.source === "codex" ? parseCodexSession(candidate.path, budget) : parseClaudeSession(candidate.path, budget);
190
- session.id = candidate.id || session.id;
191
- session.cwd = candidate.cwd || session.cwd;
192
- session.updatedAt = candidate.updatedAt || session.updatedAt;
193
- session.matchedCwd = candidate.matchedCwd;
194
- session.threadName = candidate.threadName || session.threadName;
195
- return session;
776
+ function parseClaudeUserText(text) {
777
+ const commandName = /<command-name>([\s\S]*?)<\/command-name>/.exec(text)?.[1]?.trim();
778
+ if (commandName) {
779
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1]?.trim();
780
+ return { prompt: [commandName, args].filter(Boolean).join(" ") };
781
+ }
782
+ if (/^\s*<(?:local-command-stdout|local-command-stderr|bash-input|bash-stdout|bash-stderr|task-notification)>/.test(text))
783
+ return { prompt: "" };
784
+ if (/^\[Request interrupted by user/.test(text.trim()))
785
+ return { prompt: "" };
786
+ return { prompt: text };
196
787
  }
197
- function requiredBest(candidates, source, cwd) {
198
- const best = candidates[0];
199
- if (!best)
200
- throw new Error(`No ${source} sessions were found for ${cwd}. Pass --session with a JSONL path.`);
201
- return best;
788
+ function claudeTitle(record) {
789
+ switch (record.type) {
790
+ case "custom-title":
791
+ return stringValue(record.customTitle) || undefined;
792
+ case "ai-title":
793
+ return stringValue(record.aiTitle) || undefined;
794
+ case "summary":
795
+ return stringValue(record.summary) || undefined;
796
+ default:
797
+ return undefined;
798
+ }
202
799
  }
203
- function compareCandidates(a, b) {
204
- if (a.matchedCwd !== b.matchedCwd)
205
- return a.matchedCwd ? -1 : 1;
206
- return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
800
+ export function parseGeminiSession(path, budget = DEFAULT_EXCERPT_TOKENS) {
801
+ const { metadata, messages, lineCount } = readGeminiConversation(path);
802
+ const builder = new SessionBuilder();
803
+ const id = stringValue(metadata.sessionId) || sessionIdFromPath(path);
804
+ const projectDir = geminiProjectDir(path);
805
+ for (const message of messages) {
806
+ const timestamp = stringValue(message.timestamp) || undefined;
807
+ if (message.type === "user") {
808
+ builder.message("user", geminiUserPrompt(message), timestamp);
809
+ }
810
+ else if (message.type === "gemini") {
811
+ builder.message("assistant", geminiText(message.content), timestamp);
812
+ for (const call of Array.isArray(message.toolCalls) ? message.toolCalls.map(objectValue) : []) {
813
+ readGeminiTool(builder, call, timestamp, projectDir ? join(projectDir, id, "plans") : undefined);
814
+ }
815
+ }
816
+ // info, warning, and error records are CLI notices, not conversation.
817
+ }
818
+ return buildSession({
819
+ source: "gemini",
820
+ id,
821
+ path,
822
+ cwd: projectDir ? readGeminiProjectRoot(projectDir) : undefined,
823
+ lineCount,
824
+ builder,
825
+ budget,
826
+ threadName: stringValue(metadata.summary) || undefined
827
+ });
207
828
  }
208
- function buildSession(args) {
209
- const transcript = args.transcript.filter((entry) => isUsefulText(entry.text));
210
- return {
211
- source: args.source,
212
- id: args.id,
213
- path: args.path,
214
- cwd: args.cwd,
215
- updatedAt: args.updatedAt,
216
- matchedCwd: args.matchedCwd,
217
- lineCount: args.lineCount,
218
- transcript,
219
- transcriptExcerpt: renderTranscriptExcerpt(transcript, args.budget || 12_000),
220
- supplementalPaths: args.supplementalPaths
829
+ /**
830
+ * Replays a Gemini chat file the way Gemini CLI loads it: a message record is rewritten in place each time its id
831
+ * repeats, `$set` updates metadata (or replaces every message), and `$rewindTo` drops that message and all after it.
832
+ */
833
+ function readGeminiConversation(path) {
834
+ if (/\.json$/i.test(path)) {
835
+ const record = objectValue(parseJson(readFileSync(path, "utf8")));
836
+ const messages = Array.isArray(record.messages) ? record.messages.map(objectValue) : [];
837
+ return { metadata: record, messages, lineCount: messages.length };
838
+ }
839
+ const lines = readSessionLines(path);
840
+ const messages = new Map();
841
+ const addMessages = (value) => {
842
+ for (const message of Array.isArray(value) ? value.map(objectValue) : [])
843
+ if (typeof message.id === "string")
844
+ messages.set(message.id, message);
221
845
  };
846
+ let metadata = {};
847
+ for (const line of lines) {
848
+ const record = objectValue(parseJson(line));
849
+ if (typeof record.$rewindTo === "string") {
850
+ const ids = [...messages.keys()];
851
+ const index = ids.indexOf(record.$rewindTo);
852
+ for (const messageId of index >= 0 ? ids.slice(index) : ids)
853
+ messages.delete(messageId);
854
+ }
855
+ else if (typeof record.id === "string") {
856
+ messages.set(record.id, record);
857
+ }
858
+ else if (record.$set && typeof record.$set === "object") {
859
+ const update = objectValue(record.$set);
860
+ if (Array.isArray(update.messages)) {
861
+ messages.clear();
862
+ addMessages(update.messages);
863
+ }
864
+ metadata = { ...metadata, ...update };
865
+ }
866
+ else if (typeof record.sessionId === "string") {
867
+ metadata = { ...metadata, ...record };
868
+ addMessages(record.messages);
869
+ }
870
+ }
871
+ return { metadata, messages: [...messages.values()], lineCount: lines.length };
222
872
  }
223
- function renderTranscriptExcerpt(entries, budget) {
224
- const rendered = entries.slice(-80).map((entry) => {
225
- const when = entry.timestamp ? ` ${entry.timestamp}` : "";
226
- const kind = entry.kind ? `/${entry.kind}` : "";
227
- return `### ${entry.role}${kind}${when}\n${entry.text}`;
228
- });
229
- let output = rendered.join("\n\n").trim();
230
- if (output.length > budget)
231
- output = output.slice(output.length - budget).replace(/^[\s\S]*?(?=### )/, "");
232
- return output || "No transcript excerpt captured.";
873
+ const GEMINI_REFERENCED_FILES = "--- Content from referenced files ---";
874
+ /** What the user typed. `displayContent` holds it when `@file` expansion rewrote `content`; tool results carry no text. */
875
+ function geminiUserPrompt(message) {
876
+ if (message.type !== "user")
877
+ return "";
878
+ const text = geminiText(message.displayContent ?? message.content).split(GEMINI_REFERENCED_FILES)[0];
879
+ return cleanText(text);
233
880
  }
234
- function readCodexMeta(path) {
235
- for (const line of readHeadLines(path).slice(0, 20)) {
236
- const item = parseJson(line);
237
- if (!item || typeof item !== "object")
238
- continue;
239
- const record = item;
240
- if (stringValue(record.type) !== "session_meta")
241
- continue;
242
- const payload = objectValue(record.payload);
243
- return {
244
- id: stringValue(payload.id),
245
- cwd: stringValue(payload.cwd),
246
- updatedAt: stringValue(payload.timestamp)
247
- };
248
- }
249
- return {};
881
+ function geminiText(value) {
882
+ if (typeof value === "string")
883
+ return value;
884
+ const parts = Array.isArray(value) ? value.map(objectValue) : [objectValue(value)];
885
+ return parts
886
+ .filter((part) => typeof part.text === "string" && part.thought !== true)
887
+ .map((part) => part.text)
888
+ .join("");
250
889
  }
251
- function readClaudeMeta(path) {
252
- for (const line of readHeadLines(path).slice(0, 30)) {
253
- const item = parseJson(line);
254
- if (!item || typeof item !== "object")
255
- continue;
256
- const record = item;
257
- const cwd = stringValue(record.cwd);
258
- if (!cwd)
259
- continue;
260
- return {
261
- id: stringValue(record.sessionId),
262
- cwd,
263
- updatedAt: stringValue(record.timestamp)
264
- };
890
+ function readGeminiTool(builder, call, messageTimestamp, plansDir) {
891
+ const name = stringValue(call.name);
892
+ const args = objectValue(call.args);
893
+ const status = stringValue(call.status);
894
+ const timestamp = stringValue(call.timestamp) || messageTimestamp;
895
+ switch (name) {
896
+ case "run_shell_command": {
897
+ const command = stringValue(args.command);
898
+ if (status === "cancelled") {
899
+ builder.toolCall("Shell", `${command} (cancelled)`, timestamp);
900
+ return;
901
+ }
902
+ const output = geminiToolOutput(call);
903
+ const background = args.is_background === true || output.startsWith("Command moved to background");
904
+ // The shell tool appends "Exit Code: N" only when N is non-zero.
905
+ const exitCode = parseExitCode(output) ?? (status === "error" ? 1 : status === "success" && !background ? 0 : undefined);
906
+ builder.command(command, exitCode, output, timestamp);
907
+ return;
908
+ }
909
+ case "replace":
910
+ case "write_file":
911
+ if (status !== "error" && status !== "cancelled")
912
+ builder.edit(stringValue(args.file_path), timestamp);
913
+ return;
914
+ case "write_todos":
915
+ builder.setPlan(planItemsFrom(args.todos));
916
+ return;
917
+ case "exit_plan_mode": {
918
+ const planFile = plansDir && stringValue(args.plan_filename) ? join(plansDir, basename(stringValue(args.plan_filename))) : "";
919
+ if (planFile && existsSync(planFile))
920
+ builder.planText = cleanText(readFileSync(planFile, "utf8"));
921
+ return;
922
+ }
265
923
  }
266
- return {};
924
+ const detail = stringValue(args.file_path) || stringValue(args.dir_path) || stringValue(args.pattern) || stringValue(args.query) || stringValue(args.url) || stringValue(args.description);
925
+ builder.toolCall(name, detail, timestamp);
267
926
  }
268
- function readCodexIndex() {
269
- const indexPath = join(homedir(), ".codex", "session_index.jsonl");
270
- const map = new Map();
271
- if (!existsSync(indexPath))
272
- return map;
273
- for (const line of readLines(indexPath)) {
274
- const item = parseJson(line);
275
- if (!item || typeof item !== "object")
276
- continue;
277
- const record = item;
278
- const id = stringValue(record.id);
279
- const name = stringValue(record.thread_name);
280
- if (id && name)
281
- map.set(id, name);
927
+ /** The text a tool returned to the model. The shell tool prefixes the command's output with "Output: ". */
928
+ function geminiToolOutput(call) {
929
+ const texts = (Array.isArray(call.result) ? call.result.map(objectValue) : [])
930
+ .flatMap((part) => {
931
+ const response = objectValue(objectValue(part.functionResponse).response);
932
+ return [stringValue(response.output), stringValue(response.error), stringValue(part.text)];
933
+ })
934
+ .filter(Boolean);
935
+ const text = texts.length ? texts.join("\n") : stringValue(call.resultDisplay);
936
+ return text.replace(/^Output: /, "");
937
+ }
938
+ // Field numbers below come from the protos agy embeds: gemini_coder.Step and the exa.cortex_pb step payloads.
939
+ const AGY_STEP = { userInput: 14, plannerResponse: 15, errorMessage: 17, checkpoint: 23, commandStatus: 28 };
940
+ const AGY_STATUS = { cleared: 5, canceled: 6, error: 7 };
941
+ const AGY_EDIT_TOOLS = new Set(["replace_file_content", "multi_replace_file_content", "write_to_file"]);
942
+ export function parseAntigravitySession(path, budget = DEFAULT_EXCERPT_TOKENS) {
943
+ const db = openAntigravityDb(path);
944
+ if (!db)
945
+ throw new CtxError(`Could not open the Antigravity conversation ${path}.`, "Check that the file exists and is readable.");
946
+ const builder = new SessionBuilder();
947
+ const id = antigravityId(path);
948
+ let metadata = {};
949
+ let stepCount = 0;
950
+ let checkpointTitle;
951
+ try {
952
+ metadata = antigravityTrajectoryMetadata(db);
953
+ for (const row of db.prepare("SELECT step_payload FROM steps ORDER BY idx").all()) {
954
+ if (!(row.step_payload instanceof Uint8Array))
955
+ continue;
956
+ stepCount++;
957
+ checkpointTitle = readAntigravityStep(builder, pbDecode(row.step_payload)) || checkpointTitle;
958
+ }
282
959
  }
283
- return map;
960
+ finally {
961
+ db.close();
962
+ }
963
+ const summary = readAntigravityIndex().get(id);
964
+ const firstPrompt = builder.transcript.find((entry) => entry.role === "user")?.text;
965
+ return buildSession({
966
+ source: "antigravity",
967
+ id,
968
+ path,
969
+ cwd: metadata.cwd || summary?.cwd,
970
+ lineCount: stepCount,
971
+ builder,
972
+ budget,
973
+ threadName: summary?.title || checkpointTitle || (firstPrompt ? truncate(oneLine(firstPrompt), 80) : undefined)
974
+ });
284
975
  }
285
- function latestClaudeSupplementalPaths() {
286
- return unique([
287
- ...findRecentFiles(join(homedir(), ".claude", "plans"), 5).filter((path) => path.endsWith(".md")),
288
- ...findRecentFiles(join(homedir(), ".claude", "tasks"), 5).filter((path) => path.endsWith(".json"))
289
- ]).slice(0, 8);
976
+ /** Feeds one gemini_coder.Step into the builder; returns a conversation title when a checkpoint carries one. */
977
+ function readAntigravityStep(builder, step) {
978
+ const type = pbInt(step, 1);
979
+ const status = pbInt(step, 4);
980
+ const metadata = pbMessage(step, 5);
981
+ const timestamp = pbTimestamp(pbMessage(metadata, 1));
982
+ // Steps that Antigravity has compacted out of context keep their metadata but lose their payload, so old
983
+ // prompts and replies come back empty here; the latest checkpoint summarizes them.
984
+ switch (type) {
985
+ case AGY_STEP.userInput:
986
+ builder.message("user", antigravityPrompt(pbMessage(step, 19)), timestamp);
987
+ return undefined;
988
+ case AGY_STEP.plannerResponse: {
989
+ const response = pbMessage(step, 20);
990
+ builder.message("assistant", pbString(response, 8) || pbString(response, 1), timestamp);
991
+ return undefined;
992
+ }
993
+ case AGY_STEP.checkpoint: {
994
+ const checkpoint = pbMessage(step, 30);
995
+ const summary = pbString(checkpoint, 5);
996
+ if (summary.trim())
997
+ builder.compactSummary = cleanText(summary);
998
+ return pbString(checkpoint, 10) || undefined;
999
+ }
1000
+ case AGY_STEP.commandStatus: {
1001
+ const result = pbMessage(step, 37);
1002
+ builder.commandResult(pbString(result, 1), pbString(result, 9) || pbString(result, 3), { exitCode: pbInt(result, 5) });
1003
+ return undefined;
1004
+ }
1005
+ case AGY_STEP.errorMessage:
1006
+ // The tool call failed before it ran.
1007
+ return undefined;
1008
+ }
1009
+ const call = pbMessage(metadata, 4);
1010
+ const name = pbString(call, 2);
1011
+ // System and ephemeral messages carry no tool call.
1012
+ if (name)
1013
+ readAntigravityTool(builder, name, objectValue(parseJson(pbString(call, 3))), step, status, timestamp);
1014
+ return undefined;
290
1015
  }
291
- function readLines(path) {
292
- return readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean);
1016
+ /** CortexStepUserInput: what the user sent (2), else its text items (3), else the raw query (1). */
1017
+ function antigravityPrompt(input) {
1018
+ return pbString(input, 2) || pbMessages(input, 3).map((item) => pbString(item, 1)).join("") || pbString(input, 1);
293
1019
  }
294
- function readHeadLines(path) {
295
- const stats = statSync(path);
296
- if (stats.size <= 256_000)
297
- return readLines(path);
298
- return readSlice(path, 0, 256_000).split(/\r?\n/).filter(Boolean);
1020
+ function readAntigravityTool(builder, name, args, step, status, timestamp) {
1021
+ // Newer agy builds run every tool as a generic step (140) whose result carries the typed step as a google.protobuf.Any.
1022
+ const result = pbMessage(pbMessage(step, 140), 2);
1023
+ const resultText = pbString(result, 1);
1024
+ const payload = pbMessage(result, 6);
1025
+ const typed = pbString(payload, 1).endsWith("gemini_coder.Step") ? pbDecode(pbBytes(payload, 2)) : step;
1026
+ if (name === "run_command") {
1027
+ const run = pbMessage(typed, 28);
1028
+ const command = stringValue(args.CommandLine) || pbString(run, 23) || pbString(run, 1);
1029
+ if (status === AGY_STATUS.canceled || pbInt(run, 14) === 1 || /^Encountered error in step execution: context canceled/.test(resultText.trim())) {
1030
+ builder.toolCall("Shell", `${command} (cancelled)`, timestamp);
1031
+ return;
1032
+ }
1033
+ const exitCode = pbInt(run, 6) ?? numberFromMatch(/exited with code (-?\d+)/.exec(resultText)) ?? (status === AGY_STATUS.error ? 1 : undefined);
1034
+ const output = pbString(pbMessage(run, 21), 1) || resultText;
1035
+ const commandId = pbString(run, 13);
1036
+ // A command that went to the background reports its exit code in a later command_status step.
1037
+ if (exitCode === undefined && commandId && status !== AGY_STATUS.cleared)
1038
+ builder.commandCall(commandId, command, timestamp);
1039
+ else
1040
+ builder.command(command, exitCode, output, timestamp);
1041
+ return;
1042
+ }
1043
+ if (AGY_EDIT_TOOLS.has(name)) {
1044
+ const file = stringValue(args.TargetFile);
1045
+ // Plans and walkthroughs that agy writes to its own brain folder are not repo edits.
1046
+ if (file && status !== AGY_STATUS.canceled && status !== AGY_STATUS.error && !isInside(resolve(file), resolve(antigravityHome())))
1047
+ builder.edit(file, timestamp);
1048
+ return;
1049
+ }
1050
+ const detail = stringValue(args.AbsolutePath) || stringValue(args.DirectoryPath) || stringValue(args.SearchPath) || stringValue(args.Query) || stringValue(args.query) || stringValue(args.Url);
1051
+ builder.toolCall(name, detail, timestamp);
299
1052
  }
300
- function readSessionLines(path) {
301
- const stats = statSync(path);
302
- if (stats.size <= 4_000_000)
303
- return readLines(path);
304
- const head = readSlice(path, 0, 512_000);
305
- const tailLength = Math.min(2_000_000, stats.size);
306
- const tail = readSlice(path, stats.size - tailLength, tailLength);
307
- return `${head}\n${tail}`.split(/\r?\n/).filter(Boolean);
1053
+ function numberFromMatch(match) {
1054
+ return match ? Number(match[1]) : undefined;
308
1055
  }
309
- function readSlice(path, position, length) {
310
- const fd = openSync(path, "r");
1056
+ const requireBuiltin = createRequire(import.meta.url);
1057
+ let sqliteModule;
1058
+ /** node:sqlite prints an ExperimentalWarning on load in Node 22; keep CLI output clean. */
1059
+ function loadSqlite() {
1060
+ if (sqliteModule)
1061
+ return sqliteModule;
1062
+ const emitWarning = process.emitWarning;
1063
+ process.emitWarning = ((warning, ...rest) => {
1064
+ const message = typeof warning === "string" ? warning : warning.message;
1065
+ if (/SQLite is an experimental feature/i.test(message))
1066
+ return;
1067
+ return emitWarning.call(process, warning, ...rest);
1068
+ });
311
1069
  try {
312
- const buffer = Buffer.alloc(length);
313
- const bytesRead = readSync(fd, buffer, 0, length, position);
314
- return buffer.subarray(0, bytesRead).toString("utf8");
1070
+ sqliteModule = requireBuiltin("node:sqlite");
1071
+ return sqliteModule;
315
1072
  }
316
1073
  finally {
317
- closeSync(fd);
1074
+ process.emitWarning = emitWarning;
318
1075
  }
319
1076
  }
320
- function parseJson(line) {
1077
+ /**
1078
+ * Opens an Antigravity database without changing anything next to it. A running agy keeps its newest steps in the
1079
+ * -wal file, so read through it while agy has it open. Otherwise open the file as immutable: a plain read-only open
1080
+ * of a WAL database would create -wal and -shm files in agy's folder.
1081
+ */
1082
+ function openAntigravityDb(path) {
1083
+ if (!existsSync(path))
1084
+ return undefined;
1085
+ const { DatabaseSync } = loadSqlite();
1086
+ if (existsSync(`${path}-wal`) && existsSync(`${path}-shm`)) {
1087
+ try {
1088
+ return new DatabaseSync(path, { readOnly: true });
1089
+ }
1090
+ catch {
1091
+ // Fall back to the last checkpointed state.
1092
+ }
1093
+ }
321
1094
  try {
322
- return JSON.parse(line);
1095
+ const url = pathToFileURL(path);
1096
+ url.searchParams.set("immutable", "1");
1097
+ return new DatabaseSync(url, { readOnly: true });
323
1098
  }
324
1099
  catch {
325
1100
  return undefined;
326
1101
  }
327
1102
  }
1103
+ function buildSession(args) {
1104
+ const { builder } = args;
1105
+ return {
1106
+ source: args.source,
1107
+ id: args.id,
1108
+ path: args.path,
1109
+ cwd: args.cwd,
1110
+ updatedAt: mtimeIso(args.path),
1111
+ matchedCwd: false,
1112
+ lineCount: args.lineCount,
1113
+ transcript: builder.transcript,
1114
+ transcriptExcerpt: renderTranscriptExcerpt(builder.transcript, args.budget),
1115
+ threadName: args.threadName,
1116
+ supplementalPaths: args.supplementalPaths,
1117
+ plan: builder.plan,
1118
+ planText: builder.planText,
1119
+ commands: builder.commands,
1120
+ edits: builder.edits,
1121
+ compactSummary: builder.compactSummary,
1122
+ links: unique(builder.links)
1123
+ };
1124
+ }
1125
+ /** Most recent conversation (and failures) that fits the token budget, oldest first. Routine tool calls are left out. */
1126
+ export function renderTranscriptExcerpt(entries, budgetTokens = DEFAULT_EXCERPT_TOKENS) {
1127
+ const maxChars = Math.max(500, budgetTokens * 4);
1128
+ const rendered = [];
1129
+ let used = 0;
1130
+ for (const entry of [...entries].reverse()) {
1131
+ if (entry.kind === "tool_call" || entry.kind === "edit")
1132
+ continue;
1133
+ const text = entry.role === "tool" ? entry.text : truncate(entry.text, 1_500);
1134
+ const line = `[${entry.role}] ${text}`;
1135
+ if (used + line.length > maxChars)
1136
+ break;
1137
+ rendered.unshift(line);
1138
+ used += line.length + 1;
1139
+ }
1140
+ return rendered.join("\n");
1141
+ }
1142
+ function latestClaudeSupplementalPaths() {
1143
+ const root = claudeHome();
1144
+ return unique([...findRecentFiles(join(root, "plans"), 3, /\.md$/i), ...findRecentFiles(join(root, "tasks"), 3, /\.json$/i)]);
1145
+ }
1146
+ // ---------------------------------------------------------------------------
1147
+ // Text helpers
1148
+ const NOISE_BLOCKS = [
1149
+ "system-reminder",
1150
+ "local-command-caveat",
1151
+ "permissions instructions",
1152
+ "app-context",
1153
+ "collaboration_mode",
1154
+ "skills_instructions",
1155
+ "plugins_instructions",
1156
+ "environment_context",
1157
+ "user_instructions",
1158
+ "session_context",
1159
+ "hook_context",
1160
+ "INSTRUCTIONS"
1161
+ ];
1162
+ function cleanText(text) {
1163
+ let next = text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
1164
+ for (const tag of NOISE_BLOCKS)
1165
+ next = next.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, "g"), "");
1166
+ return next.replace(/"encrypted_content":"[^"]+"/g, "").replace(/\n{3,}/g, "\n\n").trim();
1167
+ }
1168
+ function isUsefulText(text) {
1169
+ if (!text || text.length < 2)
1170
+ return false;
1171
+ if (/^(?:# AGENTS\.md instructions|# Handoff[:\s·]|<environment_context>|<user_instructions>|<turn_aborted>)/.test(text))
1172
+ return false;
1173
+ if (/You are Codex|Knowledge cutoff:|Filesystem sandboxing defines|DO NOT respond to these messages/.test(text))
1174
+ return false;
1175
+ return true;
1176
+ }
1177
+ function planItemsFrom(value) {
1178
+ if (!Array.isArray(value))
1179
+ return [];
1180
+ return value
1181
+ .map(objectValue)
1182
+ .map((entry) => ({
1183
+ text: oneLine(stringValue(entry.content) || stringValue(entry.step) || stringValue(entry.text) || stringValue(entry.subject) || stringValue(entry.description)),
1184
+ status: planStatus(entry.status)
1185
+ }))
1186
+ .filter((entry) => entry.text && entry.status !== undefined);
1187
+ }
1188
+ function planStatus(value) {
1189
+ if (value === "completed" || value === "done")
1190
+ return "completed";
1191
+ if (value === "in_progress" || value === "in-progress" || value === "active")
1192
+ return "in_progress";
1193
+ if (value === "pending" || value === "todo" || value === "not_started" || value === "blocked")
1194
+ return "pending";
1195
+ return undefined;
1196
+ }
1197
+ function patchFiles(patch) {
1198
+ return unique(Array.from(patch.matchAll(/\*\*\* (?:Add|Update|Delete) File: ([^\n\\"]+)/g), (match) => match[1].trim()));
1199
+ }
1200
+ function shellCommand(parts) {
1201
+ const strings = parts.filter((part) => typeof part === "string");
1202
+ if (strings.length >= 3 && /(?:^|\/)(?:ba|z|fi)?sh$/.test(strings[0]) && /^-l?c$/.test(strings[1]))
1203
+ return strings.slice(2).join(" ");
1204
+ return strings.join(" ");
1205
+ }
1206
+ function parseExitCode(output) {
1207
+ const match = /"exit_code"\s*:\s*(-?\d+)/.exec(output) ||
1208
+ /Process exited with code (-?\d+)/i.exec(output) ||
1209
+ /^Exit code:?\s*(-?\d+)/im.exec(output);
1210
+ return match ? Number(match[1]) : undefined;
1211
+ }
1212
+ function firstErrorLine(output) {
1213
+ const lines = output
1214
+ .split(/\r?\n/)
1215
+ .map((line) => line.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "").trim())
1216
+ .filter((line) => line && !/^(?:Exit code|Process exited|Wall time|Chunk ID|Output:)/i.test(line));
1217
+ const line = lines.find((candidate) => /(error|fail|exception|traceback|cannot|not found|denied|panic|assert|expected)/i.test(candidate)) || lines[0];
1218
+ return line ? truncate(line, 200) : undefined;
1219
+ }
1220
+ function outputText(value) {
1221
+ if (typeof value === "string") {
1222
+ const parsed = objectValue(parseJson(value));
1223
+ if (typeof parsed.output === "string") {
1224
+ const exit = numberValue(objectValue(parsed.metadata).exit_code);
1225
+ return exit === undefined ? parsed.output : `Exit code: ${exit}\n${parsed.output}`;
1226
+ }
1227
+ return value;
1228
+ }
1229
+ return collectText(value).join("\n");
1230
+ }
328
1231
  function collectText(value) {
329
1232
  if (typeof value === "string")
330
1233
  return [value];
@@ -333,78 +1236,168 @@ function collectText(value) {
333
1236
  if (!value || typeof value !== "object")
334
1237
  return [];
335
1238
  const record = value;
336
- return ["text", "content", "message", "output", "error", "body", "name", "input"].flatMap((key) => collectText(record[key]));
1239
+ return ["text", "content", "output"].flatMap((key) => collectText(record[key]));
337
1240
  }
338
- function pushEntry(entries, role, text, timestamp, kind) {
339
- const clean = cleanText(text);
340
- if (!isUsefulText(clean))
341
- return;
342
- entries.push({ role, text: clean, timestamp, kind });
343
- }
344
- function cleanText(text) {
345
- return text
346
- .replace(/\u001b\[[0-9;]*m/g, "")
347
- .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "")
348
- .replace(/<permissions instructions>[\s\S]*?<\/permissions instructions>/g, "")
349
- .replace(/<app-context>[\s\S]*?<\/app-context>/g, "")
350
- .replace(/<collaboration_mode>[\s\S]*?<\/collaboration_mode>/g, "")
351
- .replace(/<skills_instructions>[\s\S]*?<\/skills_instructions>/g, "")
352
- .replace(/<plugins_instructions>[\s\S]*?<\/plugins_instructions>/g, "")
353
- .replace(/"encrypted_content":"[^"]+"/g, "")
354
- .replace(/\n{3,}/g, "\n\n")
355
- .trim();
1241
+ function sessionIdFromPath(path) {
1242
+ return basename(path)
1243
+ .replace(/\.(jsonl|json)$/i, "")
1244
+ .replace(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-/, "");
356
1245
  }
357
- function isUsefulText(text) {
358
- if (!text || text.length < 3)
359
- return false;
360
- if (text.includes("base_instructions"))
1246
+ function matchesWorkspace(sessionCwd, scope) {
1247
+ if (!sessionCwd)
361
1248
  return false;
362
- if (text.includes("Knowledge cutoff:"))
363
- return false;
364
- if (text.includes("You are Codex"))
365
- return false;
366
- if (text.includes("Filesystem sandboxing defines"))
367
- return false;
368
- if (text.includes("# Codex desktop context"))
369
- return false;
370
- if (text.includes("### Available skills"))
371
- return false;
372
- if (text.includes("### Skill roots"))
373
- return false;
374
- if (text.includes("Tool definitions"))
375
- return false;
376
- if (text.includes("Valid channels:"))
377
- return false;
378
- if (text.includes("# Handoff:"))
379
- return false;
380
- if (text.includes("DO NOT respond to these messages"))
381
- return false;
382
- if (text.includes("deferred_tools_delta"))
383
- return false;
384
- return true;
1249
+ const target = canonicalPath(sessionCwd);
1250
+ if (target === scope.cwd)
1251
+ return true;
1252
+ return Boolean(scope.repoRoot && isInside(target, scope.repoRoot));
1253
+ }
1254
+ function isInside(path, root) {
1255
+ const rel = relative(root, path);
1256
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
1257
+ }
1258
+ function toScope(cwd, repoRoot) {
1259
+ return { cwd: canonicalPath(cwd), repoRoot: repoRoot ? canonicalPath(repoRoot) : undefined };
1260
+ }
1261
+ /** Claude Code stores projects under the cwd with every non-alphanumeric character replaced by "-". */
1262
+ export function encodeClaudeProjectPath(cwd) {
1263
+ return resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
385
1264
  }
386
1265
  function objectValue(value) {
387
- return value && typeof value === "object" ? value : {};
1266
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
388
1267
  }
389
1268
  function stringValue(value) {
390
1269
  return typeof value === "string" ? value : "";
391
1270
  }
392
- function sessionIdFromPath(path) {
393
- return basename(path).replace(/\.(jsonl|json)$/i, "").replace(/^rollout-[^-]+-[^-]+-[^-]+-/, "");
1271
+ function numberValue(value) {
1272
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
394
1273
  }
395
- function samePath(a, b) {
396
- if (!a)
397
- return false;
398
- return resolve(a) === resolve(b);
1274
+ function parseJson(line) {
1275
+ if (!line)
1276
+ return undefined;
1277
+ try {
1278
+ return JSON.parse(line);
1279
+ }
1280
+ catch {
1281
+ return undefined;
1282
+ }
399
1283
  }
400
- function parseWithMatch(session, cwd) {
401
- session.matchedCwd = samePath(session.cwd, cwd);
402
- return session;
1284
+ function mtimeIso(path) {
1285
+ return statSync(path).mtime.toISOString();
1286
+ }
1287
+ const utf8 = new TextDecoder();
1288
+ /** One level of a serialized message. Nested messages stay as bytes until asked for; a malformed tail is dropped. */
1289
+ function pbDecode(bytes) {
1290
+ const fields = new Map();
1291
+ if (!bytes)
1292
+ return fields;
1293
+ let offset = 0;
1294
+ const varint = () => {
1295
+ let value = 0n;
1296
+ for (let shift = 0n; offset < bytes.length; shift += 7n) {
1297
+ const byte = bytes[offset++];
1298
+ value |= BigInt(byte & 0x7f) << shift;
1299
+ if (byte < 0x80)
1300
+ return value;
1301
+ }
1302
+ throw new Error("truncated varint");
1303
+ };
1304
+ try {
1305
+ while (offset < bytes.length) {
1306
+ const key = varint();
1307
+ const field = Number(key >> 3n);
1308
+ const wire = Number(key & 7n);
1309
+ let value;
1310
+ if (wire === 0) {
1311
+ value = varint();
1312
+ }
1313
+ else if (wire === 2) {
1314
+ const length = Number(varint());
1315
+ if (offset + length > bytes.length)
1316
+ break;
1317
+ value = bytes.subarray(offset, offset + length);
1318
+ offset += length;
1319
+ }
1320
+ else if (wire === 1 || wire === 5) {
1321
+ offset += wire === 1 ? 8 : 4;
1322
+ continue;
1323
+ }
1324
+ else {
1325
+ break;
1326
+ }
1327
+ const values = fields.get(field);
1328
+ if (values)
1329
+ values.push(value);
1330
+ else
1331
+ fields.set(field, [value]);
1332
+ }
1333
+ }
1334
+ catch {
1335
+ // Keep the fields read so far.
1336
+ }
1337
+ return fields;
1338
+ }
1339
+ function pbBytes(message, field) {
1340
+ const value = message.get(field)?.at(-1);
1341
+ return value instanceof Uint8Array ? value : undefined;
1342
+ }
1343
+ function pbString(message, field) {
1344
+ const bytes = pbBytes(message, field);
1345
+ return bytes ? utf8.decode(bytes) : "";
1346
+ }
1347
+ function pbMessage(message, field) {
1348
+ return pbDecode(pbBytes(message, field));
1349
+ }
1350
+ function pbMessages(message, field) {
1351
+ return (message.get(field) || []).filter((value) => value instanceof Uint8Array).map(pbDecode);
1352
+ }
1353
+ /** An int32/enum/bool field; undefined when absent. */
1354
+ function pbInt(message, field) {
1355
+ const value = message.get(field)?.at(-1);
1356
+ return typeof value === "bigint" ? Number(BigInt.asIntN(32, value)) : undefined;
1357
+ }
1358
+ /** A google.protobuf.Timestamp as an ISO string. */
1359
+ function pbTimestamp(timestamp) {
1360
+ const seconds = timestamp.get(1)?.at(-1);
1361
+ if (typeof seconds !== "bigint")
1362
+ return undefined;
1363
+ const nanos = timestamp.get(2)?.at(-1);
1364
+ return new Date(Number(seconds) * 1000 + (typeof nanos === "bigint" ? Number(nanos) / 1e6 : 0)).toISOString();
1365
+ }
1366
+ // ---------------------------------------------------------------------------
1367
+ // File reading
1368
+ function readSessionLines(path) {
1369
+ const size = statSync(path).size;
1370
+ const text = size <= 16_000_000 ? readFileSync(path, "utf8") : `${readSlice(path, 0, 2_000_000)}\n${readSlice(path, size - 10_000_000, 10_000_000)}`;
1371
+ return text.split(/\r?\n/).filter(Boolean);
1372
+ }
1373
+ function readFirstLine(path, maxBytes = 2_000_000) {
1374
+ let length = 32_000;
1375
+ while (true) {
1376
+ const chunk = readSlice(path, 0, length);
1377
+ const newline = chunk.indexOf("\n");
1378
+ if (newline >= 0)
1379
+ return chunk.slice(0, newline);
1380
+ if (chunk.length < length || length >= maxBytes)
1381
+ return chunk;
1382
+ length *= 4;
1383
+ }
1384
+ }
1385
+ function readHead(path, bytes) {
1386
+ return readSlice(path, 0, bytes);
403
1387
  }
404
- function inferSourceFromPath(path) {
405
- return path.includes("/.claude/") ? "claude" : "codex";
1388
+ function readTail(path, bytes) {
1389
+ const size = statSync(path).size;
1390
+ return readSlice(path, Math.max(0, size - bytes), Math.min(bytes, size));
406
1391
  }
407
- function encodeClaudeProjectPath(cwd) {
408
- return resolve(cwd).replace(/[\/\\]/g, "-");
1392
+ function readSlice(path, position, length) {
1393
+ const fd = openSync(path, "r");
1394
+ try {
1395
+ const buffer = Buffer.alloc(length);
1396
+ const bytesRead = readSync(fd, buffer, 0, length, position);
1397
+ return buffer.subarray(0, bytesRead).toString("utf8");
1398
+ }
1399
+ finally {
1400
+ closeSync(fd);
1401
+ }
409
1402
  }
410
1403
  //# sourceMappingURL=session.js.map