@manyoars/oars 0.1.1 → 0.3.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 (240) hide show
  1. package/README.md +134 -26
  2. package/dist/api.js +1 -1
  3. package/dist/bin.js +6 -29
  4. package/dist/cli.d.ts +6 -3
  5. package/dist/cli.js +370 -72
  6. package/dist/commands/keys.d.ts +31 -0
  7. package/dist/commands/keys.js +65 -0
  8. package/dist/commands/login.d.ts +1 -1
  9. package/dist/commands/login.js +3 -5
  10. package/dist/errors.d.ts +13 -13
  11. package/dist/errors.js +28 -11
  12. package/dist/gate/checks.d.ts +68 -0
  13. package/dist/gate/checks.js +584 -0
  14. package/dist/gate/command.d.ts +14 -0
  15. package/dist/gate/command.js +61 -0
  16. package/dist/gate/hook-fill-main.d.ts +1 -0
  17. package/dist/gate/hook-fill-main.js +16 -0
  18. package/dist/gate/hook-fill.d.ts +38 -0
  19. package/dist/gate/hook-fill.js +94 -0
  20. package/dist/gate/hook-main.d.ts +1 -0
  21. package/dist/gate/hook-main.js +35 -0
  22. package/dist/gate/hook-process.d.ts +14 -0
  23. package/dist/gate/hook-process.js +64 -0
  24. package/dist/gate/hook.d.ts +46 -0
  25. package/dist/gate/hook.js +256 -0
  26. package/dist/gate/install.d.ts +25 -0
  27. package/dist/gate/install.js +126 -0
  28. package/dist/gate/local-bin.d.ts +8 -0
  29. package/dist/gate/local-bin.js +36 -0
  30. package/dist/gate/notes.d.ts +19 -0
  31. package/dist/gate/notes.js +27 -0
  32. package/dist/gate/novelty.d.ts +35 -0
  33. package/dist/gate/novelty.js +99 -0
  34. package/dist/gate/policy.d.ts +25 -0
  35. package/dist/gate/policy.js +73 -0
  36. package/dist/gate/record.d.ts +120 -0
  37. package/dist/gate/record.js +125 -0
  38. package/dist/gate/registry-state.d.ts +10 -0
  39. package/dist/gate/registry-state.js +30 -0
  40. package/dist/gate/registry.d.ts +56 -0
  41. package/dist/gate/registry.js +94 -0
  42. package/dist/gate/replay.d.ts +20 -0
  43. package/dist/gate/replay.js +257 -0
  44. package/dist/gate/report.d.ts +50 -0
  45. package/dist/gate/report.js +89 -0
  46. package/dist/gate/scope.d.ts +7 -0
  47. package/dist/gate/scope.js +27 -0
  48. package/dist/gate/transcripts.d.ts +68 -0
  49. package/dist/gate/transcripts.js +232 -0
  50. package/dist/hook-bin.d.ts +1 -0
  51. package/dist/hook-bin.js +25 -0
  52. package/dist/issuer.d.ts +16 -0
  53. package/dist/issuer.js +55 -0
  54. package/dist/main.d.ts +1 -0
  55. package/dist/main.js +64 -0
  56. package/dist/oidc.js +1 -1
  57. package/dist/sessions/actions.d.ts +35 -0
  58. package/dist/sessions/actions.js +351 -0
  59. package/dist/sessions/allow.d.ts +14 -0
  60. package/dist/sessions/allow.js +51 -0
  61. package/dist/sessions/archive.d.ts +11 -0
  62. package/dist/sessions/archive.js +38 -0
  63. package/dist/sessions/audit.d.ts +42 -0
  64. package/dist/sessions/audit.js +104 -0
  65. package/dist/sessions/baseline.d.ts +62 -0
  66. package/dist/sessions/baseline.js +188 -0
  67. package/dist/sessions/capture-runtime.d.ts +10 -0
  68. package/dist/sessions/capture-runtime.js +12 -0
  69. package/dist/sessions/cell-increments.d.ts +43 -0
  70. package/dist/sessions/cell-increments.js +209 -0
  71. package/dist/sessions/cells.d.ts +79 -0
  72. package/dist/sessions/cells.js +181 -0
  73. package/dist/sessions/codex.d.ts +53 -0
  74. package/dist/sessions/codex.js +679 -0
  75. package/dist/sessions/command.d.ts +117 -0
  76. package/dist/sessions/command.js +373 -0
  77. package/dist/sessions/copilot.d.ts +40 -0
  78. package/dist/sessions/copilot.js +432 -0
  79. package/dist/sessions/cursor.d.ts +24 -0
  80. package/dist/sessions/cursor.js +324 -0
  81. package/dist/sessions/derive.d.ts +57 -0
  82. package/dist/sessions/derive.js +320 -0
  83. package/dist/sessions/exclusion.d.ts +81 -0
  84. package/dist/sessions/exclusion.js +353 -0
  85. package/dist/sessions/extract.d.ts +179 -0
  86. package/dist/sessions/extract.js +1774 -0
  87. package/dist/sessions/fold.d.ts +17 -0
  88. package/dist/sessions/fold.js +71 -0
  89. package/dist/sessions/frame.d.ts +24 -0
  90. package/dist/sessions/frame.js +95 -0
  91. package/dist/sessions/gemini.d.ts +37 -0
  92. package/dist/sessions/gemini.js +613 -0
  93. package/dist/sessions/gold-arms.d.ts +67 -0
  94. package/dist/sessions/gold-arms.js +172 -0
  95. package/dist/sessions/gold.d.ts +197 -0
  96. package/dist/sessions/gold.js +463 -0
  97. package/dist/sessions/graph.d.ts +12 -0
  98. package/dist/sessions/graph.js +34 -0
  99. package/dist/sessions/grok.d.ts +36 -0
  100. package/dist/sessions/grok.js +421 -0
  101. package/dist/sessions/hook-calls.d.ts +3 -0
  102. package/dist/sessions/hook-calls.js +47 -0
  103. package/dist/sessions/hook-index.d.ts +53 -0
  104. package/dist/sessions/hook-index.js +157 -0
  105. package/dist/sessions/hooks.d.ts +13 -0
  106. package/dist/sessions/hooks.js +170 -0
  107. package/dist/sessions/identity.d.ts +22 -0
  108. package/dist/sessions/identity.js +101 -0
  109. package/dist/sessions/install.d.ts +54 -0
  110. package/dist/sessions/install.js +201 -0
  111. package/dist/sessions/lineage.d.ts +19 -0
  112. package/dist/sessions/lineage.js +118 -0
  113. package/dist/sessions/mcp.d.ts +6 -0
  114. package/dist/sessions/mcp.js +153 -0
  115. package/dist/sessions/merkle.d.ts +7 -0
  116. package/dist/sessions/merkle.js +28 -0
  117. package/dist/sessions/motif.d.ts +43 -0
  118. package/dist/sessions/motif.js +59 -0
  119. package/dist/sessions/package-resolve.d.ts +30 -0
  120. package/dist/sessions/package-resolve.js +183 -0
  121. package/dist/sessions/pending.d.ts +34 -0
  122. package/dist/sessions/pending.js +142 -0
  123. package/dist/sessions/pool-command.d.ts +44 -0
  124. package/dist/sessions/pool-command.js +542 -0
  125. package/dist/sessions/pool-delta.d.ts +54 -0
  126. package/dist/sessions/pool-delta.js +190 -0
  127. package/dist/sessions/pool-outbox.d.ts +63 -0
  128. package/dist/sessions/pool-outbox.js +260 -0
  129. package/dist/sessions/pool-print.d.ts +26 -0
  130. package/dist/sessions/pool-print.js +68 -0
  131. package/dist/sessions/pool-wire.d.ts +225 -0
  132. package/dist/sessions/pool-wire.js +620 -0
  133. package/dist/sessions/pool.d.ts +32 -0
  134. package/dist/sessions/pool.js +164 -0
  135. package/dist/sessions/projection.d.ts +75 -0
  136. package/dist/sessions/projection.js +165 -0
  137. package/dist/sessions/public-vocabulary.d.ts +73 -0
  138. package/dist/sessions/public-vocabulary.js +302 -0
  139. package/dist/sessions/query.d.ts +111 -0
  140. package/dist/sessions/query.js +246 -0
  141. package/dist/sessions/read-adoption.d.ts +2 -0
  142. package/dist/sessions/read-adoption.js +27 -0
  143. package/dist/sessions/read-call-shape.d.ts +2 -0
  144. package/dist/sessions/read-call-shape.js +25 -0
  145. package/dist/sessions/read-change.d.ts +2 -0
  146. package/dist/sessions/read-change.js +30 -0
  147. package/dist/sessions/read-cost.d.ts +4 -0
  148. package/dist/sessions/read-cost.js +37 -0
  149. package/dist/sessions/read-day-move.d.ts +3 -0
  150. package/dist/sessions/read-day-move.js +65 -0
  151. package/dist/sessions/read-fit.d.ts +7 -0
  152. package/dist/sessions/read-fit.js +41 -0
  153. package/dist/sessions/read-interaction.d.ts +5 -0
  154. package/dist/sessions/read-interaction.js +62 -0
  155. package/dist/sessions/read-lead-lag.d.ts +15 -0
  156. package/dist/sessions/read-lead-lag.js +81 -0
  157. package/dist/sessions/read-live.d.ts +6 -0
  158. package/dist/sessions/read-live.js +38 -0
  159. package/dist/sessions/read-method.d.ts +2 -0
  160. package/dist/sessions/read-method.js +29 -0
  161. package/dist/sessions/read-next-step.d.ts +2 -0
  162. package/dist/sessions/read-next-step.js +35 -0
  163. package/dist/sessions/read-order.d.ts +54 -0
  164. package/dist/sessions/read-order.js +62 -0
  165. package/dist/sessions/read-readable.d.ts +8 -0
  166. package/dist/sessions/read-readable.js +24 -0
  167. package/dist/sessions/read-recovery.d.ts +20 -0
  168. package/dist/sessions/read-recovery.js +97 -0
  169. package/dist/sessions/read-rows.d.ts +13 -0
  170. package/dist/sessions/read-rows.js +47 -0
  171. package/dist/sessions/read-substitution.d.ts +5 -0
  172. package/dist/sessions/read-substitution.js +51 -0
  173. package/dist/sessions/read-support.d.ts +48 -0
  174. package/dist/sessions/read-support.js +73 -0
  175. package/dist/sessions/read-tool-day.d.ts +2 -0
  176. package/dist/sessions/read-tool-day.js +26 -0
  177. package/dist/sessions/read-version-boundary.d.ts +31 -0
  178. package/dist/sessions/read-version-boundary.js +95 -0
  179. package/dist/sessions/read.d.ts +79 -0
  180. package/dist/sessions/read.js +84 -0
  181. package/dist/sessions/recall-consequence.d.ts +45 -0
  182. package/dist/sessions/recall-consequence.js +179 -0
  183. package/dist/sessions/recall-object.d.ts +137 -0
  184. package/dist/sessions/recall-object.js +77 -0
  185. package/dist/sessions/recall-pool.d.ts +19 -0
  186. package/dist/sessions/recall-pool.js +96 -0
  187. package/dist/sessions/recall-push.d.ts +26 -0
  188. package/dist/sessions/recall-push.js +95 -0
  189. package/dist/sessions/recall-reads.d.ts +117 -0
  190. package/dist/sessions/recall-reads.js +410 -0
  191. package/dist/sessions/recall-render.d.ts +19 -0
  192. package/dist/sessions/recall-render.js +168 -0
  193. package/dist/sessions/redact.d.ts +6 -0
  194. package/dist/sessions/redact.js +54 -0
  195. package/dist/sessions/repo-root.d.ts +10 -0
  196. package/dist/sessions/repo-root.js +101 -0
  197. package/dist/sessions/schema.d.ts +644 -0
  198. package/dist/sessions/schema.js +148 -0
  199. package/dist/sessions/session-shape.d.ts +73 -0
  200. package/dist/sessions/session-shape.js +342 -0
  201. package/dist/sessions/sources.d.ts +50 -0
  202. package/dist/sessions/sources.js +159 -0
  203. package/dist/sessions/steps.d.ts +105 -0
  204. package/dist/sessions/steps.js +250 -0
  205. package/dist/sessions/store.d.ts +297 -0
  206. package/dist/sessions/store.js +1478 -0
  207. package/dist/sessions/strata.d.ts +26 -0
  208. package/dist/sessions/strata.js +77 -0
  209. package/dist/sessions/survival.d.ts +25 -0
  210. package/dist/sessions/survival.js +230 -0
  211. package/dist/sessions/verify.d.ts +53 -0
  212. package/dist/sessions/verify.js +286 -0
  213. package/dist/sessions/vocab-load.d.ts +26 -0
  214. package/dist/sessions/vocab-load.js +61 -0
  215. package/dist/sessions/vscode-copilot.d.ts +38 -0
  216. package/dist/sessions/vscode-copilot.js +477 -0
  217. package/package.json +4 -5
  218. package/vocab/crates.json +1 -0
  219. package/vocab/go.json +1 -0
  220. package/vocab/harness.json +1 -0
  221. package/vocab/homebrew.json +1 -0
  222. package/vocab/manifest.json +1 -0
  223. package/vocab/maven.json +1 -0
  224. package/vocab/models.json +1 -0
  225. package/vocab/npm.json +1 -0
  226. package/vocab/programs.json +1 -0
  227. package/vocab/pypi.json +1 -0
  228. package/vocab/rubygems.json +1 -0
  229. package/dist/commands/deploy-input.d.ts +0 -35
  230. package/dist/commands/deploy-input.js +0 -107
  231. package/dist/commands/deploy.d.ts +0 -22
  232. package/dist/commands/deploy.js +0 -120
  233. package/dist/commands/status.d.ts +0 -7
  234. package/dist/commands/status.js +0 -18
  235. package/dist/commands/test-charge.d.ts +0 -11
  236. package/dist/commands/test-charge.js +0 -34
  237. package/dist/journal.d.ts +0 -26
  238. package/dist/journal.js +0 -58
  239. package/parity/deploy-plans/paid-api.json +0 -34
  240. package/parity/deploy-plans/per-task-agent.json +0 -34
@@ -0,0 +1,613 @@
1
+ // The Gemini CLI source: a Gemini CLI chat file becomes the same capture records a Claude Code transcript does.
2
+ //
3
+ // FIXTURE-DERIVED: written from the open-source Gemini CLI (github.com/google-gemini/gemini-cli, Apache-2.0, commit 89e9d25d, v0.67.0-nightly.20261020), not from
4
+ // a live store. Nothing was copied; the reader is original. Paths below are under `packages/core/src/`. `docs/sessions-store.md` says what a live run would still
5
+ // have to prove, and no vendor text promises the on-disk format (the code itself migrates `.json` to `.jsonl` and marks `$set.messages` deprecated).
6
+ //
7
+ // Where it reads from (`geminiRoot`): `$GEMINI_CLI_HOME`, else the home, then `.gemini` (`%USERPROFILE%\.gemini` on Windows; config/storage.ts, utils/paths.ts). Inside it,
8
+ // `tmp/<slug>/chats/session-<time>-<id8>.jsonl` is one session (services/chatRecordingService.ts:801-823), a subagent's is `chats/<parentSessionId>/<agentId>.jsonl`
9
+ // (:786-814), and a legacy `*.json` holds one pretty-printed object (:1647). `tmp/<slug>/.project_root` names the project folder (config/projectRegistry.ts); with a path
10
+ // that hashes to a pre-slug folder's name it is the only place a working directory exists, so a session's cwd is that folder and a directory change inside a session is not recorded. A folder from before the slug registry is named
11
+ // for sha256 of the project root and has no marker: its cwd is a path that hashes to its name (its chats' workspace directories, other markers, `projects.json`), else none. Under
12
+ // `SANDBOX=sandbox-exec` the same layout lives in `~/.cache/.gemini` (config/storage.ts:93-102), read beside the ordinary one. The prompt log `logs.json`, the
13
+ // `checkpoint-*.json` copies, and the opt-in telemetry file are not read: the first is a subset of the chat file and the others are copies or off by default.
14
+ // The file is the vendor's own journal, folded the way its loader does (services/chatRecordingService.ts:304-530): a line is
15
+ // {sessionId, projectHash, ...} the head metadata (may carry legacy `messages`)
16
+ // {id, timestamp, type, content, displayContent?, toolCalls?, tokens?, model?} a message; the same `id` appended again replaces the earlier record in its place
17
+ // {$rewindTo: id} drop that message and every later one (an unknown id clears all)
18
+ // {$patch: {id|updates, content?, toolCalls?[{id, result}], removeIds?, orderIds?}}
19
+ // {$set: {...}} metadata; a deprecated `messages` replaces the history
20
+ // A `user` message is a prompt unless its parts are all `functionResponse` (a tool result, geminiChat.ts) or it starts with `/`, `?`, `<session_context>`, or
21
+ // `<hook_context>`, the vendor's own resume filter (utils/sessionUtils.ts:97). `displayContent` is what the operator typed. A `gemini` message is a response:
22
+ // its text (`thought` parts left out), its `toolCalls[{id, name, args, result, status, timestamp}]`, and `tokens{input, output, cached, thoughts, tool, total}`
23
+ // (chatRecordingTypes.ts), one per request, never cumulative. `tokens.input` is the prompt count, which includes the cache (telemetry/uiTelemetry.ts:317-320),
24
+ // and capture's fields are disjoint, so the cache is subtracted. A shell call's `status` is `success` for a nonzero exit; the exit code is a trailer line of its
25
+ // result text (tools/shell.ts:953-979, wrapped in `<untrusted_context>` by utils/textUtils.ts), and that line decides.
26
+ import { readdir, readFile, stat } from 'node:fs/promises';
27
+ import { createHash } from 'node:crypto';
28
+ import { homedir } from 'node:os';
29
+ import { basename, dirname, extname, join } from 'node:path';
30
+ /** The Gemini folder for an environment; a test passes its own so it never reads the real one. */
31
+ export const geminiRoot = (env, home = homedir()) => {
32
+ const set = env.GEMINI_CLI_HOME;
33
+ return join(set === undefined || set === '' ? home : set, '.gemini');
34
+ };
35
+ const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
36
+ const str = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
37
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
38
+ const parse = (text) => {
39
+ try {
40
+ return JSON.parse(text);
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ };
46
+ const SAFE_ID = /^(?!\.+$)[A-Za-z0-9._-]+$/;
47
+ /** An error's message with `file` cut to its base name, so a reason that is logged names no folder. */
48
+ const reasonOf = (error, file) => (error instanceof Error ? error.message : String(error)).split(file).join(basename(file));
49
+ const missing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
50
+ const iso = (value) => {
51
+ const text = str(value);
52
+ // An absolute time only: a missing or relative one (not a date after 2000) says nothing about when.
53
+ return text !== undefined && Date.parse(text) >= Date.parse('2000-01-01T00:00:00Z') ? text : undefined;
54
+ };
55
+ const lineTexts = (text) => text.split('\n').filter((raw) => raw.trim() !== '');
56
+ /** A legacy `.json` file is one object that holds its own `sessionId`; its lines are not records. */
57
+ const wholeObject = (text) => {
58
+ const whole = parse(text);
59
+ return isObj(whole) && str(whole.sessionId) !== undefined ? whole : undefined;
60
+ };
61
+ /** The non-blank lines that are not JSON (a torn write), kept so `captureSession` counts them and the deposit line reports them. */
62
+ export const chatBadLines = (text) => (wholeObject(text) === undefined ? lineTexts(text).filter((raw) => parse(raw) === undefined) : []);
63
+ /** The text of a `PartListUnion`: a string, a part, or a list of them; a `thought` part is the model's reasoning, not its answer. */
64
+ const textOf = (value) => {
65
+ if (typeof value === 'string')
66
+ return value;
67
+ if (Array.isArray(value))
68
+ return value.map(textOf).join('');
69
+ return isObj(value) && value.thought !== true && typeof value.text === 'string' ? value.text : '';
70
+ };
71
+ /**
72
+ * What the CLI's own first turn says about the machine and the workspace: `My operating system is: <process.platform>` and the `- **Workspace Directories:**` list
73
+ * (utils/environmentContext.ts `getEnvironmentContext`, `getDirectoryContextString`). A chat records it as a user message, so the file names where it ran even when
74
+ * the project folder holds no `.project_root` (the marker, or for a hash-named folder a path that hashes to its name, is the cwd). A pass that removed the turn leaves the file without it, and both come back empty.
75
+ */
76
+ const sessionContext = (text) => {
77
+ const whole = wholeObject(text);
78
+ const messages = whole !== undefined && Array.isArray(whole.messages) ? whole.messages : [];
79
+ const records = whole === undefined ? lineTexts(text).map(parse) : [whole, ...messages];
80
+ let platform;
81
+ const directories = [];
82
+ for (const record of records) {
83
+ const body = isObj(record) && record.type === 'user' ? textOf(record.content) : '';
84
+ // Upstream's context turn opens with the tag; a typed prompt that merely quotes it must not frame the session.
85
+ if (!body.trimStart().startsWith('<session_context>'))
86
+ continue;
87
+ platform ??= /^My operating system is: (\S+)$/m.exec(body)?.[1];
88
+ for (const line of /^- \*\*Workspace Directories:\*\*\n((?: {2}- .+\n?)+)/m.exec(body)?.[1]?.split('\n') ?? []) {
89
+ const listed = /^ {2}- (.+)$/.exec(line)?.[1];
90
+ if (listed !== undefined)
91
+ directories.push(listed);
92
+ }
93
+ }
94
+ return { platform, directories };
95
+ };
96
+ /** A path written the Windows way: a drive letter or a UNC share. */
97
+ const WINDOWS_PATH = /^(?:[A-Za-z]:[\\/]|\\\\)/;
98
+ /** The sha256 hex of a project root, which names a project folder from before the slug registry (config/storage.ts:263-265; the chat head's `projectHash` is the same, utils/paths.ts:318). */
99
+ const isHashName = (name) => /^[0-9a-f]{64}$/.test(name);
100
+ const hashOf = (path) => createHash('sha256').update(path).digest('hex');
101
+ /**
102
+ * The text of a response the way the CLI stores it: zero-width characters and HTML comments (repeatedly, until none is left) stripped, then trimmed (core/geminiChat.ts:1710-1719).
103
+ * The stored message holds this; the history copy a sync re-records holds the raw parts (geminiChat.ts:1823, setHistory at :1391), so a copy is compared on this form.
104
+ */
105
+ const storedText = (value) => {
106
+ let text = textOf(value).replace(/[\u200B-\u200D\uFEFF\u200E\u200F]/g, '');
107
+ let previous;
108
+ do {
109
+ previous = text;
110
+ text = text.replace(/<!--[^]*?-->/g, '');
111
+ } while (text !== previous);
112
+ return text.trim();
113
+ };
114
+ /** A re-recorded copy (a history sync makes it with no usage, calls, or model) of `message`: the same kind of message with the same stored text. */
115
+ const isCopyOf = (candidate, message) => candidate.type === message.type && !isObj(candidate.tokens) && !(Array.isArray(candidate.toolCalls) && candidate.toolCalls.length > 0) && candidate.model === undefined && storedText(candidate.content) === storedText(message.content);
116
+ /** The messages a chat file holds once its records are folded the way the vendor's loader folds them (chatRecordingService.ts:304-530), and its metadata. */
117
+ const foldChat = (text) => {
118
+ const whole = wholeObject(text);
119
+ const records = whole === undefined ? lineTexts(text).map(parse) : [whole];
120
+ if (records.length > 0 && !records.some(isObj))
121
+ throw new Error('no parseable line');
122
+ const messages = new Map();
123
+ let meta = {};
124
+ const seed = (list) => {
125
+ for (const message of Array.isArray(list) ? list : []) {
126
+ const id = isObj(message) ? str(message.id) : undefined;
127
+ if (isObj(message) && id !== undefined)
128
+ messages.set(id, message);
129
+ }
130
+ };
131
+ // `masked`: the patch removes this message in the same record (a masking pass). Its `result` is then the masked turn (toolOutputMaskingService.ts:224-231), which drops the
132
+ // `error` key and rewrites the output, so a call that already holds its result keeps it and the pre-sync result decides (chatRecordingService.ts:1500-1525,1577-1587).
133
+ const patchOne = (patch, masked = false) => {
134
+ const existing = isObj(patch) ? messages.get(str(patch.id) ?? '') : undefined;
135
+ if (!isObj(patch) || existing === undefined)
136
+ return;
137
+ if (patch.content !== undefined)
138
+ existing.content = patch.content;
139
+ if (Array.isArray(patch.toolCalls) && existing.type === 'gemini' && Array.isArray(existing.toolCalls)) {
140
+ const held = existing.toolCalls;
141
+ for (const raw of patch.toolCalls) {
142
+ const change = raw;
143
+ const target = isObj(change) ? held.find((candidate) => isObj(candidate) && candidate.id === change.id) : undefined;
144
+ if (isObj(change) && isObj(target) && 'result' in change && !(masked && partsOf(target.result).length > 0))
145
+ target.result = change.result;
146
+ }
147
+ }
148
+ };
149
+ // A history sync (compression, masking) re-records the kept turns under fresh ids with no `tokens`, `toolCalls`, or `model`, then lists every old id in `removeIds`
150
+ // (chatRecordingService.ts:1427-1590; geminiChat.ts:412-417,456,1386-1395). The vendor's loader drops those ids; this reader keeps the removed message, which is the
151
+ // only record of its prompt, usage, calls, and errors, and hides the copy instead, so each is read once and with its own time.
152
+ const echoes = new Set();
153
+ // A copy's id to the id of the message it copies, followed through a second pass: upstream's rewind can only name a copy (chatRecordingService.ts:1380,1410), which here is the original.
154
+ const original = new Map();
155
+ const retire = (ids) => {
156
+ const gone = new Set(ids.filter((id) => typeof id === 'string'));
157
+ for (const id of gone) {
158
+ const removed = messages.get(id);
159
+ if (removed === undefined)
160
+ continue;
161
+ // The newest match first: the copies are recorded last, after any older message that happens to read the same.
162
+ const copy = [...messages.entries()].reverse().find(([other, message]) => !gone.has(other) && !echoes.has(other) && isCopyOf(message, removed));
163
+ if (copy !== undefined) {
164
+ echoes.add(copy[0]);
165
+ original.set(copy[0], original.get(id) ?? id);
166
+ }
167
+ }
168
+ };
169
+ for (const record of records) {
170
+ if (!isObj(record))
171
+ continue;
172
+ if (typeof record.$rewindTo === 'string') {
173
+ const ids = [...messages.keys()];
174
+ const at = ids.indexOf(original.get(record.$rewindTo) ?? record.$rewindTo);
175
+ const named = ids.indexOf(record.$rewindTo);
176
+ // Upstream keeps everything before the named copy (chatRecordingService.ts:1380-1410), which includes the hidden copies of earlier turns; a later pass lists them in
177
+ // `removeIds`, and `retire` must still find them.
178
+ if (at === -1)
179
+ messages.clear();
180
+ else
181
+ for (const [offset, id] of ids.slice(at).entries())
182
+ if (!(at + offset < named && echoes.has(id)))
183
+ messages.delete(id);
184
+ }
185
+ else if (isObj(record.$patch)) {
186
+ const patch = record.$patch;
187
+ patchOne(patch);
188
+ const removeIds = Array.isArray(patch.removeIds) ? patch.removeIds : [];
189
+ for (const update of Array.isArray(patch.updates) ? patch.updates : [])
190
+ patchOne(update, isObj(update) && removeIds.includes(update.id));
191
+ retire(removeIds);
192
+ if (Array.isArray(patch.orderIds)) {
193
+ const ordered = patch.orderIds.flatMap((id) => (typeof id === 'string' && messages.has(id) ? [id] : []));
194
+ // The messages the list does not name stay first, in their order; the named ones follow in the listed order.
195
+ const entries = [...messages.entries()].sort(([a], [b]) => ordered.indexOf(a) - ordered.indexOf(b));
196
+ messages.clear();
197
+ for (const [id, message] of entries)
198
+ messages.set(id, message);
199
+ }
200
+ }
201
+ else if (str(record.id) !== undefined) {
202
+ messages.set(String(record.id), record);
203
+ }
204
+ else if (isObj(record.$set)) {
205
+ if (Array.isArray(record.$set.messages)) {
206
+ messages.clear();
207
+ seed(record.$set.messages);
208
+ }
209
+ meta = { ...meta, ...record.$set };
210
+ }
211
+ else if (str(record.sessionId) !== undefined && str(record.projectHash) !== undefined) {
212
+ meta = { ...meta, ...record };
213
+ seed(record.messages);
214
+ }
215
+ }
216
+ return { meta, messages: [...messages.entries()].filter(([id]) => !echoes.has(id)).map(([, message]) => message) };
217
+ };
218
+ const partsOf = (value) => (Array.isArray(value) ? value.filter(isObj) : isObj(value) ? [value] : []);
219
+ /** A user message the operator did not type: slash commands, help, and context the CLI or a hook injects (utils/sessionUtils.ts:97). */
220
+ const NOT_TYPED = ['/', '?', '<session_context>', '<hook_context>'];
221
+ /**
222
+ * User turns the CLI itself puts to the model, which the chat file records like a typed prompt (geminiChat.ts:553-567; `addHistory`, :1345-1352). Each opens with the text its writer
223
+ * fixes: loop recovery re-sends the operator's `displayContent` beside it (core/client.ts:1338-1354, so the recorded prompt would read twice); the IDE context (client.ts:503,613);
224
+ * the `!cmd` turn (cli useExecutionLifecycle.ts:61-66); the manual plan exit note (cli useGeminiStream.ts:2060, utils/approvalModeUtils.ts:36-39); and the directory context
225
+ * (client.ts:396, utils/environmentContext.ts:37). `Please continue.` (client.ts:930) is the whole text, so it is matched exactly. An AfterAgent hook's `continueReason`
226
+ * (client.ts:1052,1061) is free text; it is sent with the operator's `displayContent` (client.ts:1061-1067), so the turn reads as the operator's prompt again, which is left as a known limit
227
+ * because a repeated `/cmd` writes a like shape. The auto-nudge after a tool round with an empty reply (cli useGeminiStream.ts:720,1796-1811; core agent/legacy-agent-session.ts:254-258)
228
+ * goes through `submitQuery` and is recorded as a `user` message (geminiChat.ts:563). The other `[System:` nudges (geminiChat.ts:263,269) only reach `contentsToUse` and are never recorded.
229
+ */
230
+ const INJECTED = [
231
+ 'System: Potential loop detected.',
232
+ "Here is the user's editor context as a JSON object.",
233
+ "Here is a summary of changes in the user's editor context, in JSON format.",
234
+ 'I ran the following shell command:\n',
235
+ 'User has manually exited Plan Mode. Switching to ',
236
+ '- **Workspace Directories:**',
237
+ '[System: You successfully executed a tool but returned an empty response.',
238
+ ];
239
+ const NEXT_SPEAKER = 'Please continue.';
240
+ /** What a compression records in place of the history it replaced: the summary as a user turn, then this canned reply as the model's (context/chatCompressionService.ts:665-673). */
241
+ const SUMMARY = '<state_snapshot>';
242
+ /** What upstream closes a turn with when a stream fails after a tool response (geminiChat.ts:118,921-934, patched onto the message; or a tokenless `gemini` message at the next sync, chatRecordingService.ts:1465-1480). No model wrote it. */
243
+ const INTERRUPTED = '[The previous response was interrupted before it completed.]';
244
+ /**
245
+ * A reply the CLI wrote and no model did: a `gemini` message with text and no usage, calls, or model. A model's text always carries `model` (geminiChat.ts:1807-1812), while the
246
+ * synthetic writers set none: the canned reply after a compression summary (:1815), each turn of a saved chat loaded with `/resume resume <tag>` or `/chat resume`
247
+ * (chatCommand.ts:228, slashCommandProcessor.ts:553, geminiChat.ts:1386-1395), and the sync that records history (chatRecordingService.ts:1476-1481). The interrupted placeholder is
248
+ * a tokenless message too, but it is dropped as empty, not as a reply.
249
+ */
250
+ const isCanned = (message) => {
251
+ const text = textOf(message.content).trim();
252
+ return message.type === 'gemini' && text !== '' && text !== INTERRUPTED && !(Array.isArray(message.toolCalls) && message.toolCalls.length > 0) && !isObj(message.tokens) && message.model === undefined;
253
+ };
254
+ /** Gemini tools the reader names in the Claude Code vocabulary, so the extractor classifies them; the Gemini name stays in `native` for the action table. */
255
+ const RENAMED = new Map([
256
+ ['replace', 'Edit'],
257
+ ['write_file', 'Write'],
258
+ ['read_file', 'Read'],
259
+ ['glob', 'Glob'],
260
+ ['grep_search', 'Grep'],
261
+ ['search_file_content', 'Grep'],
262
+ ['google_web_search', 'WebSearch'],
263
+ ['web_fetch', 'WebFetch'],
264
+ ]);
265
+ const TIMED_OUT = /^Command was automatically cancelled because it exceeded the timeout of [\d.]+ minutes without output\./;
266
+ const PATH_TOOLS = new Set(['Edit', 'Write', 'Read']);
267
+ const CLOSE = '</untrusted_context>';
268
+ const THROUGH_OPEN = /^[^]*?<untrusted_context(?: [^>]*)?>\n/;
269
+ /**
270
+ * The text inside the `<untrusted_context>` wrapper. Two things the executor does to a long or hook-modified shell result leave the closing tag short of the end of
271
+ * the text, so the LAST closing tag decides (the wrapper escapes any inside the output, utils/textUtils.ts:182): a hook note appended after the wrapper
272
+ * (scheduler/tool-executor.ts:131-133), and a truncation that rewrites the wrapped text to a header line, the first 20%, a marker, and the last 80%
273
+ * (utils/fileUtils.ts:828-833, tool-executor.ts:219-236). The header and anything before the opening tag go; the tail, which holds the trailer, stays.
274
+ */
275
+ const unwrap = (text) => {
276
+ const trimmed = text.trim();
277
+ const end = trimmed.lastIndexOf(CLOSE);
278
+ if (end === -1)
279
+ return text;
280
+ return trimmed.slice(0, end).replace(/\n$/, '').replace(THROUGH_OPEN, '');
281
+ };
282
+ /** The suffix `truncateToolOutput` puts after the head of a stored result over the 64 KB cap (utils/tool-utils.ts:178-205), with the saved file's path when there is one. */
283
+ const CUT_SHORT = /\n\.{3} \[Tool output truncated to conserve memory(?:\. For full output see: [^\]]*)?\]$/;
284
+ /**
285
+ * What a call's result holds: the output and error of the `functionResponse` whose `id` is the call's, else (when none matches) the first one, else any plain text. A history
286
+ * sync writes the whole user turn, every parallel call's response, as the result of each call in it (chatRecordingService.ts:1500-1525), so the first response is not the call's.
287
+ */
288
+ const responseOf = (result, callId) => {
289
+ const all = partsOf(result).flatMap((part) => (isObj(part.functionResponse) && isObj(part.functionResponse.response) ? [{ id: part.functionResponse.id, response: part.functionResponse.response }] : []));
290
+ const own = all.filter((entry) => entry.id === callId);
291
+ const responses = (own.length > 0 ? own : all).map((entry) => entry.response);
292
+ const output = responses.map((response) => str(response.output)).find((text) => text !== undefined);
293
+ return { output: output ?? (responses.length === 0 ? textOf(result) : ''), error: responses.map((response) => str(response.error)).find((text) => text !== undefined) };
294
+ };
295
+ /** A shell result's trailer, in the order the tool writes it (tools/shell.ts:953-979): `Exit Code`, `Signal`, `Background PIDs`, `Process Group PGID`. */
296
+ const shellResult = (text) => {
297
+ const lines = unwrap(text).split('\n');
298
+ const pop = (prefix) => {
299
+ const last = lines.at(-1);
300
+ if (last === undefined || !last.startsWith(prefix))
301
+ return undefined;
302
+ lines.pop();
303
+ return last.slice(prefix.length);
304
+ };
305
+ pop('Process Group PGID: ');
306
+ pop('Background PIDs: ');
307
+ const signal = pop('Signal: ');
308
+ const code = pop('Exit Code: ');
309
+ const exit = code !== undefined && /^-?\d+$/.test(code) ? Number(code) : undefined;
310
+ const body = lines.join('\n').replace(/^Output: /, '');
311
+ return { output: body === '(empty)' ? '' : body, exit, signal };
312
+ };
313
+ /** The chat file as the lines `captureSession` reads; `cwd` is the project folder: the marker (`.project_root`), or for a hash-named folder a path that hashes to its name. Gemini CLI records no other working directory. */
314
+ export const chatLines = (text, fallbackId, cwd) => {
315
+ const { meta, messages } = foldChat(text);
316
+ const lines = [];
317
+ const base = { sessionId: str(meta.sessionId) ?? fallbackId, ...(cwd === null ? {} : { cwd }) };
318
+ // The shell tool runs what `getShellConfiguration` picks for the machine it ran on: PowerShell on Windows, bash elsewhere (utils/shell-utils.ts:658-697). The context turn names
319
+ // the machine; a chat without it is Windows when its working directory is written the Windows way.
320
+ const { platform } = sessionContext(text);
321
+ const shellTool = (platform === undefined ? cwd !== null && WINDOWS_PATH.test(cwd) : platform === 'win32') ? 'PowerShell' : 'Bash';
322
+ let at;
323
+ let sequence = 0;
324
+ const stamp = (value) => {
325
+ const candidate = iso(value);
326
+ if (candidate !== undefined && (at === undefined || Date.parse(candidate) >= Date.parse(at)))
327
+ at = candidate;
328
+ return at ?? new Date(0).toISOString();
329
+ };
330
+ const result = (id, failure, when, output) => {
331
+ sequence += 1;
332
+ lines.push({
333
+ ...base,
334
+ type: 'user',
335
+ uuid: `${fallbackId}-r${String(sequence)}`,
336
+ timestamp: when,
337
+ // A call has one time, not a start and an end, so no duration is claimed.
338
+ noDuration: true,
339
+ // The output follows an `Exit code 0` line, the shape a Claude Code Bash result has, so a `[branch sha]` line is still found for commit survival.
340
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: failure !== undefined, content: failure ?? (output === '' ? '' : `Exit code 0\n${output}`) }] },
341
+ });
342
+ };
343
+ // The CLI's reply (see `isCanned`) answers the text user turn recorded just before it, which is therefore the CLI's too: a compression's summary, whatever it opens with, or a loaded chat's turn. Of the summary: the model is asked to reason in a scratchpad first and its text is stored as
344
+ // written (context/chatCompressionService.ts:603,614,643,668), and upstream itself tests for the tag with `includes` (:588). A summary whose reply is gone still opens with the tag.
345
+ const summaries = new Set(messages.flatMap((message, index) => (isCanned(message) ? messages.slice(Math.max(index - 1, 0), index).filter((before) => before.type === 'user') : [])));
346
+ for (const message of messages) {
347
+ const when = stamp(message.timestamp);
348
+ if (message.type === 'user') {
349
+ // A user message made wholly of tool results is the model's own turn, not a prompt (geminiChat.ts:527,547); one that also holds typed text is a prompt.
350
+ const parts = partsOf(message.content);
351
+ if (parts.length > 0 && parts.every((part) => isObj(part.functionResponse)))
352
+ continue;
353
+ // The vendor's filter reads the text SENT (`content`, chatRecordingService.ts:220-227); a custom command sends its expansion and shows `/cmd` (nonInteractiveCli.ts:289-300),
354
+ // so the shown text is only what is recorded.
355
+ const sent = textOf(message.content).trim();
356
+ if (sent === '' || sent === NEXT_SPEAKER || NOT_TYPED.some((prefix) => sent.startsWith(prefix)) || INJECTED.some((prefix) => sent.startsWith(prefix)) || sent.startsWith(SUMMARY) || summaries.has(message))
357
+ continue;
358
+ const shown = textOf(message.displayContent);
359
+ // Without a displayContent the prompt is the sent text, less a BeforeAgent hook's part, which the client appends beside the typed one (core/client.ts:985-989; the ACP path sends no displayContent, acpSession.ts:434).
360
+ const typed = shown.trim() === '' ? textOf(Array.isArray(message.content) ? message.content.filter((part) => !(isObj(part) && typeof part.text === 'string' && part.text.startsWith('<hook_context>'))) : message.content) : shown;
361
+ sequence += 1;
362
+ lines.push({ ...base, type: 'user', uuid: `${fallbackId}-u${String(sequence)}`, timestamp: when, message: { role: 'user', content: [{ type: 'text', text: typed }] } });
363
+ }
364
+ else if (message.type === 'gemini') {
365
+ const answer = textOf(message.content).trim() === INTERRUPTED ? '' : textOf(message.content);
366
+ const calls = (Array.isArray(message.toolCalls) ? message.toolCalls : []).filter(isObj);
367
+ const tokens = isObj(message.tokens) ? message.tokens : undefined;
368
+ // A response of only a thought (the synthetic "binary content" note) says nothing.
369
+ if (answer.trim() === '' && calls.length === 0 && tokens === undefined)
370
+ continue;
371
+ // The canned reply after a compression summary is the CLI's, not a response: it has no usage, calls, or model.
372
+ if (isCanned(message))
373
+ continue;
374
+ const content = answer.trim() === '' ? [] : [{ type: 'text', text: answer }];
375
+ const held = [];
376
+ for (const entry of calls) {
377
+ const id = str(entry.id);
378
+ const native = str(entry.name);
379
+ if (id === undefined || native === undefined)
380
+ continue;
381
+ const args = isObj(entry.args) ? entry.args : {};
382
+ const name = native === 'run_shell_command' ? shellTool : (RENAMED.get(native) ?? native);
383
+ const path = str(args.file_path);
384
+ const input = name === 'Bash' || name === 'PowerShell' ? (str(args.command) === undefined ? {} : { command: args.command }) : PATH_TOOLS.has(name) ? (path === undefined ? {} : { file_path: path }) : args;
385
+ content.push({ type: 'tool_use', id, name, ...(name === native || name === 'PowerShell' ? {} : { native }), input });
386
+ held.push({ id, native, shell: native === 'run_shell_command', call: entry });
387
+ }
388
+ sequence += 1;
389
+ const cached = num(tokens?.cached) ?? 0;
390
+ const thoughts = num(tokens?.thoughts);
391
+ const usage = tokens === undefined
392
+ ? {}
393
+ : {
394
+ usage: {
395
+ // `input` is the prompt count and includes the cache reads (uiTelemetry.ts:317-320); capture's fields are disjoint.
396
+ input_tokens: Math.max((num(tokens.input) ?? 0) - cached, 0),
397
+ cache_read_input_tokens: cached,
398
+ cache_creation_input_tokens: 0,
399
+ // `output` is candidatesTokenCount and `thoughts` is counted apart from it (chatRecordingTypes.ts:21-25), but thinking is part of the output, which verify holds.
400
+ output_tokens: (num(tokens.output) ?? 0) + (thoughts ?? 0),
401
+ ...(thoughts === undefined ? {} : { output_tokens_details: { thinking_tokens: thoughts } }),
402
+ },
403
+ };
404
+ const model = str(message.model);
405
+ lines.push({
406
+ ...base,
407
+ type: 'assistant',
408
+ uuid: `${fallbackId}-a${String(sequence)}`,
409
+ requestId: message.id,
410
+ timestamp: when,
411
+ ...(model === undefined ? {} : { requestedModel: model }),
412
+ message: { role: 'assistant', content, ...usage },
413
+ });
414
+ for (const { id, shell, call } of held) {
415
+ const status = str(call.status);
416
+ if (status !== 'success' && status !== 'error' && status !== 'cancelled')
417
+ continue;
418
+ const finished = stamp(call.timestamp);
419
+ const { output: raw, error } = responseOf(call.result, id);
420
+ // The cap keeps the head only, so the closing tag and the exit code are gone: the outcome is unknown, as for a call with no result yet.
421
+ if (shell && status === 'success' && error === undefined && !raw.includes(CLOSE) && CUT_SHORT.test(raw.trimEnd()))
422
+ continue;
423
+ const parsed = shell ? shellResult(raw) : undefined;
424
+ const output = parsed?.output ?? '';
425
+ // On the inactivity timeout the tool writes one sentence and no trailer, and the executor still records `success` (tools/shell.ts:929-933, tool-executor.ts:144-154).
426
+ // It has no `Output: ` prefix (tools/shell.ts:929-933), so it is tested on the unwrapped text, not on `output`, which a command printing that sentence first would also match.
427
+ const timedOut = shell ? TIMED_OUT.exec(unwrap(raw).trim()) : null;
428
+ // Under the `summarizeToolOutput` setting upstream stores only the wrapped summary: no `Output: ` and no trailer, and `success` whatever the exit code (tools/shell.ts:1175-1187).
429
+ // A success that opens with none of the shell tool's own forms is unsettled, as for a cut-short call.
430
+ if (shell && status === 'success' && error === undefined && timedOut === null && !/^(?:Output: |Command moved to background)/.test(unwrap(raw).trim()))
431
+ continue;
432
+ let failure;
433
+ if (status !== 'success' || error !== undefined) {
434
+ failure = error ?? unwrap(raw).trim();
435
+ if (failure === '')
436
+ failure = 'tool failed';
437
+ }
438
+ else if (parsed?.exit !== undefined && parsed.exit !== 0) {
439
+ // A nonzero exit code is a failure whatever `status` says. Upstream writes only `result.exitCode` here (tools/shell.ts:953-979) and no negative code is handled apart.
440
+ failure = `Exit code ${String(parsed.exit)}\n${output}`;
441
+ }
442
+ else if (parsed?.signal !== undefined) {
443
+ failure = output === '' ? `Signal: ${parsed.signal}` : output;
444
+ }
445
+ else if (timedOut !== null) {
446
+ failure = timedOut[0];
447
+ }
448
+ result(id, failure, finished, output);
449
+ }
450
+ }
451
+ }
452
+ return lines;
453
+ };
454
+ const projectRootOf = async (slug) => {
455
+ try {
456
+ return str((await readFile(join(slug, '.project_root'), 'utf8')).trim()) ?? null;
457
+ }
458
+ catch {
459
+ // A pre-slug folder (named for a hash of the path) has no marker; its sessions get no folder.
460
+ return null;
461
+ }
462
+ };
463
+ /** The session id, kind (`main` or `subagent`), and start time a chat file's head names, read from the first line of a `.jsonl` file or the object of a `.json` file. */
464
+ const headOf = (file, text) => {
465
+ const first = extname(file) === '.json' ? wholeObject(text) : parse(lineTexts(text)[0] ?? '');
466
+ return { id: isObj(first) ? str(first.sessionId) : undefined, kind: isObj(first) ? str(first.kind) : undefined, start: isObj(first) ? (str(first.startTime) ?? '') : '' };
467
+ };
468
+ /** True when the file holds no prompt or response: a head written at a chat's start and nothing after it (chatRecordingService.ts:855-865). A torn file is not empty; it is named when read. */
469
+ const holdsNothing = (text, id) => {
470
+ try {
471
+ return chatBadLines(text).length === 0 && chatLines(text, id, null).length === 0;
472
+ }
473
+ catch {
474
+ return false;
475
+ }
476
+ };
477
+ /** The project roots a runtime folder's registry knows (`projects.json`, config/projectRegistry.ts:15-22): its keys. A missing or torn registry names none. */
478
+ const registered = async (runtime) => {
479
+ const registry = parse(await readFile(join(runtime, 'projects.json'), 'utf8').catch(() => ''));
480
+ return isObj(registry) && isObj(registry.projects) ? Object.keys(registry.projects) : [];
481
+ };
482
+ export const openGeminiStore = async (root) => {
483
+ // Under `SANDBOX=sandbox-exec` the CLI keeps its runtime state, chats included, in `~/.cache/.gemini` instead (config/storage.ts:93-102); the same layout.
484
+ const runtimes = [root, join(dirname(root), '.cache', '.gemini')];
485
+ const unreadable = [];
486
+ const bases = [];
487
+ for (const runtime of runtimes) {
488
+ const base = join(runtime, 'tmp');
489
+ try {
490
+ bases.push({ runtime, slugs: (await readdir(base, { withFileTypes: true })).map((entry) => ({ name: entry.name, directory: entry.isDirectory() })) });
491
+ }
492
+ catch (error) {
493
+ if (!missing(error))
494
+ unreadable.push({ file: base, reason: reasonOf(error, base) });
495
+ }
496
+ }
497
+ if (bases.length === 0) {
498
+ if (unreadable.length === 0)
499
+ return undefined;
500
+ return { sessions: [], transcript: () => Promise.reject(new Error('no sessions')), rejected: 0, unreadable };
501
+ }
502
+ // A file is a candidate under its session id.
503
+ const found = new Map();
504
+ let rejected = 0;
505
+ const list = async (dir) => {
506
+ try {
507
+ return (await readdir(dir, { withFileTypes: true })).map((entry) => ({ name: entry.name, directory: entry.isDirectory() })).sort((a, b) => a.name.localeCompare(b.name));
508
+ }
509
+ catch (error) {
510
+ // A project folder with no chats yet is no session; any other failure is reported.
511
+ if (!missing(error))
512
+ unreadable.push({ file: dir, reason: reasonOf(error, dir) });
513
+ return [];
514
+ }
515
+ };
516
+ const add = async (file, dir, folder, parent) => {
517
+ const name = basename(file, extname(file));
518
+ try {
519
+ const info = await stat(file);
520
+ const text = await readFile(file, 'utf8');
521
+ const head = headOf(file, text);
522
+ // A subagent with no parent set is written at the root of `chats/` (chatRecordingService.ts:787,813-814), and the vendor's own listing skips it by `kind` (cli/src/utils/sessionUtils.ts:301).
523
+ if (parent === null && head.kind === 'subagent')
524
+ return;
525
+ // A subagent's file is named for the agent, which is unique under its parent; a main session's id is the head's.
526
+ const named = parent === null ? (head.id ?? name) : name;
527
+ const id = parent === null ? named : `${parent}-${named}`;
528
+ if (!SAFE_ID.test(id)) {
529
+ rejected += 1;
530
+ return;
531
+ }
532
+ // Every new chat appends a head (geminiChat.ts:453), so a launch and quit, or a /clear, leaves a file with nothing in it: no session, and not an unreadable one.
533
+ if (holdsNothing(text, named))
534
+ return;
535
+ const session = { id, folder, file, size: info.size, mtimeMs: Math.floor(info.mtimeMs), parent };
536
+ // A `.json` file migrated to `.jsonl` can leave both, with one name; the newer one is the session. Files of other names under one id are other chats (below).
537
+ const key = `${id}/${name}`;
538
+ const held = found.get(key);
539
+ if (held === undefined || held.session.mtimeMs < session.mtimeMs)
540
+ found.set(key, { session, start: head.start, stem: name, dir, directories: folder === null ? sessionContext(text).directories : [] });
541
+ }
542
+ catch (error) {
543
+ unreadable.push({ file, reason: reasonOf(error, file) });
544
+ }
545
+ };
546
+ // Paths a hash-named folder can be matched against: every project root a marker or a registry names. The hash cannot be reversed, only checked.
547
+ const known = new Set();
548
+ const hashed = new Map();
549
+ for (const { runtime, slugs } of bases) {
550
+ for (const key of await registered(runtime))
551
+ known.add(key);
552
+ for (const slug of slugs.filter((entry) => entry.directory).sort((a, b) => a.name.localeCompare(b.name))) {
553
+ const dir = join(runtime, 'tmp', slug.name);
554
+ const folder = await projectRootOf(dir);
555
+ if (folder !== null)
556
+ known.add(folder);
557
+ else if (isHashName(slug.name))
558
+ hashed.set(dir, slug.name);
559
+ const chats = join(dir, 'chats');
560
+ for (const entry of await list(chats)) {
561
+ if (!entry.directory) {
562
+ if (['.json', '.jsonl'].includes(extname(entry.name)))
563
+ await add(join(chats, entry.name), dir, folder, null);
564
+ }
565
+ else if (SAFE_ID.test(entry.name)) {
566
+ for (const inner of await list(join(chats, entry.name)))
567
+ if (!inner.directory && extname(inner.name) === '.jsonl')
568
+ await add(join(chats, entry.name, inner.name), dir, folder, entry.name);
569
+ }
570
+ else {
571
+ rejected += 1;
572
+ }
573
+ }
574
+ }
575
+ }
576
+ // A folder from before the slug registry is named for sha256 of its project root and has no marker. A path counts only when it hashes to the folder's name: the registry's
577
+ // and the markers' roots, and the workspace directories its own chats name. A folder none of them match keeps no cwd.
578
+ const resolved = new Map();
579
+ for (const [dir, name] of hashed) {
580
+ const own = [...found.values()].filter((entry) => entry.dir === dir).flatMap((entry) => entry.directories);
581
+ const match = [...own, ...known].find((candidate) => hashOf(candidate) === name);
582
+ if (match !== undefined)
583
+ resolved.set(dir, match);
584
+ }
585
+ // An AfterAgent hook's `clearContext` resets the chat, and the reset starts a new file under the same id (core/client.ts:1032,1050; chatRecordingService.ts:780,811-818); `/clear`
586
+ // changes the id first (cli clearCommand.ts:38). Each file is a chat of its own, so the earliest by its head's start time keeps the id (a file name does not order two files of one
587
+ // minute) and a later one is `<id>-<file name>`, which keeps its cursor stable.
588
+ const byId = new Map();
589
+ for (const entry of found.values())
590
+ byId.set(entry.session.id, [...(byId.get(entry.session.id) ?? []), entry]);
591
+ const listed = [];
592
+ for (const [id, files] of byId) {
593
+ files.sort((a, b) => a.start.localeCompare(b.start) || a.stem.localeCompare(b.stem));
594
+ files.forEach((entry, position) => {
595
+ const named = position === 0 ? id : `${id}-${entry.stem}`;
596
+ if (SAFE_ID.test(named))
597
+ listed.push({ ...entry.session, id: named, folder: entry.session.folder ?? resolved.get(entry.dir) ?? null });
598
+ else
599
+ rejected += 1;
600
+ });
601
+ }
602
+ const sessions = listed.sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id));
603
+ return {
604
+ sessions,
605
+ rejected,
606
+ unreadable,
607
+ transcript: async (session) => {
608
+ const raw = await readFile(session.file, 'utf8');
609
+ const lines = [...chatLines(raw, session.id, session.folder).map((line) => JSON.stringify(line)), ...chatBadLines(raw)];
610
+ return lines.join('\n') + (lines.length === 0 ? '' : '\n');
611
+ },
612
+ };
613
+ };