@100xprompt/brain 0.0.0-stage → 0.1.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 (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,521 @@
1
+ import { readFileSync } from "node:fs"
2
+ import type { Attachment, SessionHeader, Turn, Usage } from "../../session/types.ts"
3
+ import { asText, clip, iso, noteModel, toolKindOf, type lossCounter } from "../../session/util.ts"
4
+
5
+ // Facts about Gemini CLI's session records, shared by its reader. Checked against the installed 0.33.1
6
+ // (whole-file chats/session-*.json and checkpoints/*.json on this Mac) and against upstream main
7
+ // (packages/core/src/services/chatRecordingService.ts and chatRecordingTypes.ts), which writes the
8
+ // append-only .jsonl. See docs/sessions/2026-11-08-session-formats.md, entry 4.
9
+
10
+ export const TOOL = "gemini-cli"
11
+ export const LABEL = "Gemini CLI"
12
+
13
+ type Lost = ReturnType<typeof lossCounter>
14
+
15
+ /**
16
+ * The note brain puts first in a continued session's first user message, as its own text part: the model
17
+ * reads it, the screen shows the message's displayContent without it. Readers drop it and record the source.
18
+ */
19
+ export const continuedNote = (label: string, id: string, when: string) =>
20
+ `[This session continues ${label} session ${id}, carried over by brain session on ${when}. The conversation below is its history; what could not carry over is left out. Check files before relying on details, since they may have changed.]`
21
+ export const CONTINUED_NOTE = /^\[This session continues .+? session (\S+), carried over by brain session/
22
+
23
+ /**
24
+ * Gemini CLI's compression replaces the history with its summary as a user message (the model writes it
25
+ * inside <state_snapshot>) and a fixed model acknowledgement; current versions persist that in the session.
26
+ */
27
+ export const SNAPSHOT_OPEN = "<state_snapshot>"
28
+ export const SNAPSHOT_ACK = "Got it. Thanks for the additional context!"
29
+ /** A portable compaction's summary as the user message Gemini CLI's compression writes. */
30
+ export const snapshotOf = (label: string, summary: string) =>
31
+ `${SNAPSHOT_OPEN}\n[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${summary}\n</state_snapshot>`
32
+ /** The summary inside a <state_snapshot> message, or undefined when the text is not one. */
33
+ export const snapshotText = (text: string) => {
34
+ const t = text.trim()
35
+ if (!t.startsWith(SNAPSHOT_OPEN)) return undefined
36
+ // The header snapshotOf adds is brain's, not part of the summary.
37
+ return t.slice(SNAPSHOT_OPEN.length).replace(/<\/state_snapshot>\s*$/, "").trim().replace(/^\[Summary of the earlier conversation in .+?, which replaced the turns before it\]\n\n/, "")
38
+ }
39
+
40
+ /** A Part from @google/genai, as Gemini CLI stores it in messages, tool results and checkpoints. */
41
+ export interface Part {
42
+ text?: string
43
+ /** A thought summary part (readable). */
44
+ thought?: boolean
45
+ /** Gemini 3 signs function calls and thoughts; signatures never cross to another model. */
46
+ thoughtSignature?: string
47
+ functionCall?: { id?: string; name?: string; args?: unknown }
48
+ functionResponse?: { id?: string; name?: string; response?: unknown }
49
+ inlineData?: { mimeType?: string; data?: string }
50
+ fileData?: { mimeType?: string; fileUri?: string }
51
+ }
52
+
53
+ /** PartListUnion: a string, one Part, or a list of either. */
54
+ export type PartList = string | Part | Array<string | Part>
55
+
56
+ export interface ToolCallRecord {
57
+ id: string
58
+ name: string
59
+ args?: unknown
60
+ result?: PartList | null
61
+ status?: string
62
+ timestamp?: string
63
+ resultDisplay?: unknown
64
+ }
65
+
66
+ export interface MessageRecord {
67
+ id: string
68
+ timestamp?: string
69
+ /** user | gemini | info | error | warning */
70
+ type: string
71
+ content?: PartList
72
+ toolCalls?: ToolCallRecord[]
73
+ thoughts?: Array<{ subject?: string; description?: string; timestamp?: string }>
74
+ tokens?: { input?: number; output?: number; cached?: number; thoughts?: number } | null
75
+ model?: string
76
+ }
77
+
78
+ export interface Conversation {
79
+ sessionId: string
80
+ projectHash?: string
81
+ startTime?: string
82
+ lastUpdated?: string
83
+ /** A model-written summary of the session (newer versions), used as its title. */
84
+ summary?: string
85
+ kind?: string
86
+ messages: MessageRecord[]
87
+ }
88
+
89
+ export interface Loaded {
90
+ conv: Conversation
91
+ /** Record kinds the replay did not know, by name. */
92
+ unknown: Map<string, number>
93
+ /** Lines that were not JSON (a half-written last line, say). */
94
+ bad: number
95
+ }
96
+
97
+ const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
98
+
99
+ function asConversation(v: unknown): Conversation | undefined {
100
+ if (!isObj(v) || typeof v.sessionId !== "string" || !Array.isArray(v.messages)) return undefined
101
+ return { ...(v as Conversation), messages: v.messages.filter((m: unknown) => isObj(m) && typeof m.id === "string") }
102
+ }
103
+
104
+ /**
105
+ * A session file, whichever way it was written: the older whole-file JSON (one ConversationRecord,
106
+ * rewritten every turn) or the current JSONL log, replayed. Undefined when it is neither.
107
+ */
108
+ export function loadConversation(path: string): Loaded | undefined {
109
+ let text: string
110
+ try {
111
+ text = readFileSync(path, "utf8")
112
+ } catch {
113
+ return undefined
114
+ }
115
+ const whole = () => {
116
+ try {
117
+ const conv = asConversation(JSON.parse(text))
118
+ return conv ? { conv, unknown: new Map<string, number>(), bad: 0 } : undefined
119
+ } catch {
120
+ return undefined
121
+ }
122
+ }
123
+ // Gemini falls back the same way: a .jsonl whose replay finds no metadata is tried as legacy JSON.
124
+ return path.endsWith(".json") ? whole() ?? replay(text) : replay(text) ?? whole()
125
+ }
126
+
127
+ /**
128
+ * Replays a JSONL session the way chatRecordingService's loader does: the first line is metadata; a
129
+ * message line adds a message, or replaces an earlier one with the same id in place (that is how tool
130
+ * results and token counts are added after the fact); {$set} merges metadata (a `messages` array there is
131
+ * a legacy full-history checkpoint that replaces everything); {$patch} edits messages by id, removes
132
+ * some, or reorders them; {$rewindTo: id} drops that message and everything after it.
133
+ */
134
+ export function replay(text: string): Loaded | undefined {
135
+ let meta: Record<string, any> = {}
136
+ const msgs = new Map<string, MessageRecord>()
137
+ const unknown = new Map<string, number>()
138
+ let bad = 0
139
+ const addAll = (list: unknown) => {
140
+ if (Array.isArray(list)) for (const m of list) if (isObj(m) && typeof m.id === "string") msgs.set(m.id, m as MessageRecord)
141
+ }
142
+ const patchOne = (p: Record<string, any>) => {
143
+ const m = msgs.get(p.id)
144
+ if (!m) return
145
+ if (p.content !== undefined) m.content = p.content
146
+ if (Array.isArray(p.toolCalls) && m.toolCalls) {
147
+ for (const tp of p.toolCalls) {
148
+ const tc = m.toolCalls.find((t) => t.id === tp?.id)
149
+ if (tc && "result" in tp) tc.result = tp.result
150
+ }
151
+ }
152
+ }
153
+ for (const line of text.split("\n")) {
154
+ if (!line.trim()) continue
155
+ let r: unknown
156
+ try {
157
+ r = JSON.parse(line)
158
+ } catch {
159
+ bad++
160
+ continue
161
+ }
162
+ if (!isObj(r)) {
163
+ bad++
164
+ continue
165
+ }
166
+ if (typeof r.$rewindTo === "string") {
167
+ let found = false
168
+ for (const id of [...msgs.keys()]) {
169
+ if (id === r.$rewindTo) found = true
170
+ if (found) msgs.delete(id)
171
+ }
172
+ if (!found) msgs.clear()
173
+ } else if (isObj(r.$patch)) {
174
+ const p = r.$patch
175
+ if (typeof p.id === "string") patchOne(p)
176
+ if (Array.isArray(p.updates)) for (const u of p.updates) if (isObj(u) && typeof u.id === "string") patchOne(u)
177
+ if (Array.isArray(p.removeIds)) for (const id of p.removeIds) if (typeof id === "string") msgs.delete(id)
178
+ if (Array.isArray(p.orderIds)) {
179
+ // Messages named in orderIds move to the end in that order; the rest keep their places before them.
180
+ const ordered = p.orderIds.filter((id: unknown) => typeof id === "string" && msgs.has(id)) as string[]
181
+ const keep = new Set(ordered)
182
+ const entries = [...[...msgs].filter(([id]) => !keep.has(id)), ...ordered.map((id) => [id, msgs.get(id)!] as const)]
183
+ msgs.clear()
184
+ for (const [id, m] of entries) msgs.set(id, m)
185
+ }
186
+ } else if (typeof r.id === "string") {
187
+ msgs.set(r.id, r as MessageRecord)
188
+ } else if (isObj(r.$set)) {
189
+ const { messages, ...rest } = r.$set
190
+ if (Array.isArray(messages)) {
191
+ msgs.clear()
192
+ addAll(messages)
193
+ }
194
+ meta = { ...meta, ...rest }
195
+ } else if (typeof r.sessionId === "string" && typeof r.projectHash === "string") {
196
+ const { messages, ...rest } = r
197
+ meta = { ...meta, ...rest }
198
+ addAll(messages)
199
+ } else {
200
+ const name = Object.keys(r)[0] ?? "empty"
201
+ unknown.set(name, (unknown.get(name) ?? 0) + 1)
202
+ }
203
+ }
204
+ if (typeof meta.sessionId !== "string") return undefined
205
+ return { conv: { ...(meta as Omit<Conversation, "messages">), sessionId: meta.sessionId, messages: [...msgs.values()] }, unknown, bad }
206
+ }
207
+
208
+ /** The parts of a PartListUnion as a list. */
209
+ export function toParts(c: PartList | null | undefined): Part[] {
210
+ if (c === undefined || c === null) return []
211
+ if (typeof c === "string") return [{ text: c }]
212
+ if (Array.isArray(c)) return c.map((p) => (typeof p === "string" ? { text: p } : p)).filter(isObj)
213
+ return isObj(c) ? [c] : []
214
+ }
215
+
216
+ /** Readable text of a functionResponse.response: tools answer {output}, {error}, or both. */
217
+ function responseText(r: unknown): { text: string; ok: boolean } {
218
+ if (isObj(r) && ("output" in r || "error" in r)) {
219
+ const text = [asText(r.output), asText(r.error)].filter(Boolean).join("\n")
220
+ return { text, ok: !r.error }
221
+ }
222
+ return { text: asText(r), ok: true }
223
+ }
224
+
225
+ /** Text of a tool result (its functionResponse parts plus any text parts), whether it failed, and binary parts. */
226
+ export function resultText(result: PartList | null | undefined): { text: string; ok: boolean; binary: number } {
227
+ const out: string[] = []
228
+ let ok = true
229
+ let binary = 0
230
+ for (const p of toParts(result)) {
231
+ if (p.functionResponse) {
232
+ const r = responseText(p.functionResponse.response)
233
+ out.push(r.text)
234
+ ok &&= r.ok
235
+ } else if (typeof p.text === "string") out.push(p.text)
236
+ else if (p.inlineData || p.fileData) binary++
237
+ }
238
+ return { text: out.filter(Boolean).join("\n"), ok, binary }
239
+ }
240
+
241
+ /**
242
+ * Gemini's per-message token counts. Its `input` includes the cached part and its `output` leaves out
243
+ * thinking; the portable record keeps cached input apart and counts thinking as output, as Claude's and
244
+ * OpenAI's counts (and so the Claude Code and Codex readers) do.
245
+ */
246
+ function usageOf(t: MessageRecord["tokens"]): Usage | undefined {
247
+ if (!t) return undefined
248
+ const cached = typeof t.cached === "number" && t.cached > 0 ? t.cached : 0
249
+ const u: Usage = {}
250
+ if (typeof t.input === "number") u.input = Math.max(0, t.input - cached)
251
+ if (typeof t.output === "number") u.output = t.output + (typeof t.thoughts === "number" ? t.thoughts : 0)
252
+ if (cached) u.cacheRead = cached
253
+ return Object.keys(u).length ? u : undefined
254
+ }
255
+
256
+ /** Keeps tool call ids unique within a session (a rewound turn can reuse one) and names calls that have none. */
257
+ export function idMaker() {
258
+ const seen = new Set<string>()
259
+ let n = 0
260
+ return (id: string | undefined): string => {
261
+ const base = id || `gemini_call_${++n}`
262
+ let out = base
263
+ for (let i = 2; seen.has(out); i++) out = `${base}#${i}`
264
+ seen.add(out)
265
+ return out
266
+ }
267
+ }
268
+
269
+ type CallTurn = Extract<Turn, { kind: "tool_call" }>
270
+
271
+ /**
272
+ * Pairs function calls with their responses across the API history kept in checkpoints, where a
273
+ * functionCall part often has no id and only the functionResponse carries one: by id when both have it,
274
+ * otherwise the oldest pending call with the same name, otherwise the oldest pending call.
275
+ */
276
+ export function pairer(newId: ReturnType<typeof idMaker>) {
277
+ // `src` is the call's own id as recorded; the turn's id can differ when idMaker had to make it unique.
278
+ const pending: Array<{ turn: CallTurn; src?: string; name: string }> = []
279
+ const answered = new Set<string>()
280
+ return {
281
+ call(fc: NonNullable<Part["functionCall"]>, at: string | undefined): CallTurn {
282
+ const name = fc.name ?? "unknown"
283
+ const turn: CallTurn = { kind: "tool_call", ...(at ? { at } : {}), id: newId(fc.id), name, toolKind: toolKindOf(name), input: fc.args ?? {} }
284
+ pending.push({ turn, ...(fc.id ? { src: fc.id } : {}), name })
285
+ return turn
286
+ },
287
+ /** The id of the call this response answers, or undefined when no call is waiting for it. */
288
+ respond(fr: NonNullable<Part["functionResponse"]>): string | undefined {
289
+ let i = fr.id ? pending.findIndex((p) => p.src === fr.id) : -1
290
+ if (i < 0) i = pending.findIndex((p) => !p.src && p.name === fr.name)
291
+ if (i < 0) i = pending.findIndex((p) => !p.src)
292
+ if (i < 0) return undefined
293
+ const [p] = pending.splice(i, 1)
294
+ // A call without an id takes its response's id, so the pair reads naturally.
295
+ if (!p!.src && fr.id) p!.turn.id = newId(fr.id)
296
+ if (fr.id) answered.add(fr.id)
297
+ return p!.turn.id
298
+ },
299
+ /** The call recorded with this id, while it still waits for its result. */
300
+ waiting: (id: string | undefined) => (id ? pending.find((p) => p.src === id)?.turn : undefined),
301
+ /** Marks a call answered by a result found elsewhere (the tool call record itself). */
302
+ settle(turn: CallTurn, id: string | undefined) {
303
+ const i = pending.findIndex((p) => p.turn === turn)
304
+ if (i >= 0) pending.splice(i, 1)
305
+ if (id) answered.add(id)
306
+ },
307
+ /** Whether a response with this id was paired already and no call with it waits: a second copy of it. */
308
+ repeated: (id: string | undefined) => Boolean(id && answered.has(id) && !pending.some((p) => p.src === id)),
309
+ /** Calls still waiting, in order. */
310
+ open: () => pending.map((p) => p.turn),
311
+ }
312
+ }
313
+
314
+ export interface Ctx {
315
+ h: SessionHeader
316
+ lost: Lost
317
+ newId: ReturnType<typeof idMaker>
318
+ pair: ReturnType<typeof pairer>
319
+ /** File edits waiting for their call's result, which a later user message carries, by call id. */
320
+ edits: Map<string, Turn>
321
+ }
322
+
323
+ export function newCtx(h: SessionHeader, lost: Lost): Ctx {
324
+ const newId = idMaker()
325
+ return { h, lost, newId, pair: pairer(newId), edits: new Map() }
326
+ }
327
+
328
+ /**
329
+ * The turns in one message's content. Text becomes a user or assistant turn; thought parts become
330
+ * reasoning; functionCall and functionResponse parts become tool calls and results paired by `ctx.pair`.
331
+ */
332
+ export function partsToTurns(content: PartList | undefined, role: "user" | "model", at: string | undefined, ctx: Ctx, model?: string, usage?: Usage): Turn[] {
333
+ const out: Turn[] = []
334
+ let text = ""
335
+ let attachments: Attachment[] = []
336
+ const flush = () => {
337
+ if (role === "user" && (text.trim() || attachments.length)) out.push({ kind: "user", ...(at ? { at } : {}), text, ...(attachments.length ? { attachments } : {}) })
338
+ if (role === "model" && text.trim()) out.push({ kind: "assistant", ...(at ? { at } : {}), text, ...(model ? { model } : {}) })
339
+ text = ""
340
+ attachments = []
341
+ }
342
+ for (const p of toParts(content)) {
343
+ if (p.thoughtSignature) ctx.lost.add("thought signatures")
344
+ if (typeof p.text === "string") {
345
+ if (p.thought) {
346
+ flush()
347
+ if (p.text.trim()) out.push({ kind: "reasoning", ...(at ? { at } : {}), text: p.text })
348
+ } else text += p.text
349
+ } else if (p.functionCall) {
350
+ flush()
351
+ out.push(ctx.pair.call(p.functionCall, at))
352
+ } else if (p.functionResponse) {
353
+ flush()
354
+ // Newer versions can record a response twice; Gemini CLI keeps the first one per round, and so do we.
355
+ if (ctx.pair.repeated(p.functionResponse.id)) continue
356
+ const callId = ctx.pair.respond(p.functionResponse)
357
+ if (!callId) {
358
+ ctx.lost.add("tool results without a call")
359
+ continue
360
+ }
361
+ const r = responseText(p.functionResponse.response)
362
+ const c = clip(r.text)
363
+ out.push({ kind: "tool_result", ...(at ? { at } : {}), callId, ok: r.ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
364
+ const edit = ctx.edits.get(callId)
365
+ if (edit) {
366
+ out.push(edit)
367
+ ctx.edits.delete(callId)
368
+ }
369
+ } else if (p.fileData?.fileUri) {
370
+ attachments.push({ kind: "file", path: p.fileData.fileUri, ...(p.fileData.mimeType ? { mime: p.fileData.mimeType } : {}) })
371
+ } else if (p.inlineData) {
372
+ ctx.lost.add("inline images and files")
373
+ } else if (!p.thoughtSignature) {
374
+ ctx.lost.add(`unknown part types: ${Object.keys(p)[0] ?? "empty"}`)
375
+ }
376
+ }
377
+ flush()
378
+ if (usage) {
379
+ const last = [...out].reverse().find((t) => t.kind === "assistant") as Extract<Turn, { kind: "assistant" }> | undefined
380
+ if (last) last.usage = usage
381
+ }
382
+ return out
383
+ }
384
+
385
+ /** A file edit's unified diff, which edits keep in their display (FileDiff). */
386
+ function editOf(tc: ToolCallRecord, at: string | undefined, callId: string): Turn | undefined {
387
+ const d = tc.resultDisplay
388
+ if (!isObj(d) || typeof d.fileDiff !== "string") return undefined
389
+ const path = typeof d.filePath === "string" ? d.filePath : typeof d.fileName === "string" ? d.fileName : undefined
390
+ return path ? { kind: "file_edit", ...(at ? { at } : {}), path, patch: d.fileDiff, callId } : undefined
391
+ }
392
+
393
+ /**
394
+ * A tool call recorded in a gemini message, with its result (stored inside the call) and the edit it made.
395
+ * Newer versions also keep the call in the message's content, where it has become a tool_call already, and
396
+ * record its response in the user message after it (`answeredLater`); then this adds neither again, the
397
+ * way Gemini CLI's own resume (convertSessionToClientHistory) does.
398
+ */
399
+ function toolCallTurns(tc: ToolCallRecord, fallbackAt: string | undefined, ctx: Ctx, answeredLater: boolean): Turn[] {
400
+ const at = iso(tc.timestamp) ?? fallbackAt
401
+ const name = typeof tc.name === "string" ? tc.name : "unknown"
402
+ const out: Turn[] = []
403
+ let call = ctx.pair.waiting(tc.id)
404
+ if (!call) {
405
+ call = ctx.pair.call({ ...(tc.id ? { id: tc.id } : {}), name, args: tc.args ?? {} }, at)
406
+ out.push(call)
407
+ }
408
+ const edit = editOf(tc, at, call.id)
409
+ if (answeredLater) {
410
+ if (edit) ctx.edits.set(call.id, edit)
411
+ return out
412
+ }
413
+ ctx.pair.settle(call, tc.id)
414
+ const hasResult = tc.result !== undefined && tc.result !== null
415
+ const r = resultText(tc.result)
416
+ if (r.binary) ctx.lost.add("binary tool output")
417
+ if (!hasResult) ctx.lost.add("tool results not recorded")
418
+ const display = typeof tc.resultDisplay === "string" ? tc.resultDisplay : ""
419
+ const text = hasResult ? r.text : display || `(no result recorded; the call's status was ${tc.status ?? "unknown"})`
420
+ const c = clip(text)
421
+ // A {$patch} adds the result without touching the status, which can still say "executing".
422
+ const ok = hasResult && r.ok && tc.status !== "error" && tc.status !== "cancelled"
423
+ out.push({ kind: "tool_result", ...(at ? { at } : {}), callId: call.id, ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
424
+ if (edit) out.push(edit)
425
+ return out
426
+ }
427
+
428
+ /** A thought summary as one readable text. */
429
+ const thoughtText = (t: { subject?: string; description?: string }) => [t.subject, t.description].filter((x) => typeof x === "string" && x.trim()).join("\n")
430
+
431
+ /**
432
+ * User messages Gemini CLI itself leaves out when it resumes a session (isIgnoredUserContent): commands,
433
+ * and the session and hook context it injects again on its own.
434
+ */
435
+ export function ignoredUserContent(content: PartList | undefined): boolean {
436
+ const parts = toParts(content)
437
+ if (parts.some((p) => p.functionResponse)) return false
438
+ const text = parts.map((p) => (typeof p.text === "string" && !p.thought ? p.text : "")).join("").trim()
439
+ return /^(\/|\?|<session_context>|<hook_context>)/.test(text)
440
+ }
441
+
442
+ /** The ids of the tool responses in a user message; "" for a response without one. */
443
+ const responseIds = (m: MessageRecord) => (m.type === "user" ? toParts(m.content).flatMap((p) => (p.functionResponse ? [p.functionResponse.id ?? ""] : [])) : [])
444
+
445
+ /** The portable turns for a recorded conversation's messages. */
446
+ export function messagesToTurns(messages: MessageRecord[], ctx: Ctx): Turn[] {
447
+ const turns: Turn[] = []
448
+ let afterSnapshot = false
449
+ messages.forEach((m, i) => {
450
+ const at = iso(m.timestamp)
451
+ const snapshot = afterSnapshot
452
+ afterSnapshot = false
453
+ switch (m.type) {
454
+ case "user": {
455
+ if (ignoredUserContent(m.content)) {
456
+ ctx.lost.add("Gemini CLI commands and injected context")
457
+ break
458
+ }
459
+ let parts = toParts(m.content)
460
+ const note = parts[0]?.text?.match(CONTINUED_NOTE)
461
+ if (note) {
462
+ ctx.h.parent = { id: note[1]!, kind: "continued" }
463
+ parts = parts.slice(1)
464
+ }
465
+ const summary = !parts.some((p) => p.functionResponse) ? snapshotText(parts.map((p) => (typeof p.text === "string" && !p.thought ? p.text : "")).join("")) : undefined
466
+ if (summary !== undefined) {
467
+ turns.push({ kind: "compaction", ...(at ? { at } : {}), summary })
468
+ afterSnapshot = true
469
+ } else turns.push(...partsToTurns(parts, "user", at, ctx))
470
+ break
471
+ }
472
+ case "gemini": {
473
+ // The acknowledgement Gemini CLI's compression adds after its summary is part of the compaction.
474
+ if (snapshot && !m.toolCalls?.length && toParts(m.content).map((p) => p.text ?? "").join("").trim() === SNAPSHOT_ACK) break
475
+ // Newer versions keep the model's own parts in content (thoughts, calls); thoughts[] repeats them.
476
+ if (!toParts(m.content).some((p) => p.thought)) {
477
+ for (const t of m.thoughts ?? []) {
478
+ const text = thoughtText(t)
479
+ if (text) turns.push({ kind: "reasoning", ...((iso(t.timestamp) ?? at) ? { at: iso(t.timestamp) ?? at } : {}), text })
480
+ }
481
+ }
482
+ noteModel(ctx.h, m.model)
483
+ turns.push(...partsToTurns(m.content, "model", at, ctx, m.model, usageOf(m.tokens)))
484
+ // Responses the user messages up to the next gemini message carry, as Gemini CLI's resume finds them.
485
+ const later = new Set<string>()
486
+ for (let j = i + 1; j < messages.length && messages[j]!.type !== "gemini"; j++) for (const id of responseIds(messages[j]!)) later.add(id)
487
+ for (const tc of m.toolCalls ?? []) if (isObj(tc)) turns.push(...toolCallTurns(tc, at, ctx, tc.id ? later.has(tc.id) : later.has("")))
488
+ break
489
+ }
490
+ // UI notices (cancelled requests, quota messages, API errors): what the person saw, not the conversation.
491
+ case "info":
492
+ case "error":
493
+ case "warning":
494
+ ctx.lost.add(`${m.type} notices`)
495
+ break
496
+ default:
497
+ ctx.lost.add(`unknown record types: ${String(m.type)}`)
498
+ }
499
+ })
500
+ return turns
501
+ }
502
+
503
+ /**
504
+ * Results for calls that never got one (a cancelled call, or a checkpoint taken just before its tool ran),
505
+ * placed right after their calls, so every call stays paired. The output says that nothing was recorded.
506
+ */
507
+ export function closeOpenCalls(turns: Turn[], open: CallTurn[], ctx: Ctx, why: (call: CallTurn) => string): Turn[] {
508
+ if (!open.length) return turns
509
+ ctx.lost.add("tool results not recorded", open.length)
510
+ const waiting = new Set(open)
511
+ const out: Turn[] = []
512
+ for (const t of turns) {
513
+ out.push(t)
514
+ if (t.kind !== "tool_call" || !waiting.has(t)) continue
515
+ out.push({ kind: "tool_result", ...(t.at ? { at: t.at } : {}), callId: t.id, ok: false, output: `(no result recorded: ${why(t)})` })
516
+ const edit = ctx.edits.get(t.id)
517
+ if (edit) out.push(edit)
518
+ ctx.edits.delete(t.id)
519
+ }
520
+ return out
521
+ }