@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,228 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs"
2
+ import { extname, resolve } from "node:path"
3
+ import type { Attachment, Session, Turn, Usage } from "../../session/types.ts"
4
+
5
+ // What the Gemini family's writers (Gemini CLI, and its forks Qwen Code and iFlow CLI) share: the portable
6
+ // session's flat turns regrouped into the exchanges these tools keep (a person's message; one model
7
+ // response with its readable thoughts, its text and its tool calls, each with the result and edits that
8
+ // answered it; a compaction summary), and portable tool calls mapped onto a target's own tools.
9
+
10
+ export type Call = Extract<Turn, { kind: "tool_call" }>
11
+ export type Result = Extract<Turn, { kind: "tool_result" }>
12
+ export type Edit = Extract<Turn, { kind: "file_edit" }>
13
+
14
+ export interface Answered {
15
+ call: Call
16
+ /** Undefined when the session never recorded one; writers close the call as failed. */
17
+ result?: Result
18
+ edits: Edit[]
19
+ }
20
+
21
+ export type Exchange =
22
+ | { kind: "user"; at?: string; text: string; attachments: Attachment[] }
23
+ | { kind: "model"; at?: string; model?: string; usage?: Usage; thoughts: Array<{ at?: string; text: string }>; text: string; calls: Answered[] }
24
+ | { kind: "compaction"; at?: string; summary: string }
25
+
26
+ export type Drop = (what: string, n?: number) => void
27
+
28
+ /** Counts what a writer drops, by phrase, and lists it the way `WriteResult.lost` wants. */
29
+ export function drops() {
30
+ const counts = new Map<string, number>()
31
+ const drop: Drop = (what, n = 1) => counts.set(what, (counts.get(what) ?? 0) + n)
32
+ return { drop, list: () => [...counts].map(([what, n]) => `${what} (${n})`) }
33
+ }
34
+
35
+ /**
36
+ * The session's exchanges, in order. A tool result closes the response that made its call, so a call made
37
+ * after it starts a new response, as it does in these tools (the model saw the result before calling
38
+ * again). With `fromLastCompaction`, a compacted session starts from its last summary, the way a tool that
39
+ * replaces the history with its summary would load it; the turns before it are dropped and counted.
40
+ */
41
+ export function regroup(session: Session, opts: { fromLastCompaction: boolean; keepReasoning: boolean; drop: Drop }): Exchange[] {
42
+ const { drop } = opts
43
+ let turns = session.turns
44
+ if (opts.fromLastCompaction) {
45
+ let last = -1
46
+ turns.forEach((t, i) => {
47
+ if (t.kind === "compaction") last = i
48
+ })
49
+ if (last > 0) {
50
+ drop("turns before the last compaction, which its summary replaced", last)
51
+ turns = turns.slice(last)
52
+ }
53
+ }
54
+ const out: Exchange[] = []
55
+ type Model = Extract<Exchange, { kind: "model" }>
56
+ let cur: Model | undefined
57
+ const answered = new Map<string, Answered>()
58
+ const flush = () => {
59
+ if (cur && (cur.text.trim() || cur.calls.length || cur.thoughts.length)) out.push(cur)
60
+ cur = undefined
61
+ }
62
+ const model = (at: string | undefined) => (cur ??= { kind: "model", ...(at ? { at } : {}), thoughts: [], text: "", calls: [] })
63
+ for (const t of turns) {
64
+ switch (t.kind) {
65
+ case "user":
66
+ flush()
67
+ if (t.text.trim() || t.attachments?.length) out.push({ kind: "user", ...(t.at ? { at: t.at } : {}), text: t.text, attachments: t.attachments ?? [] })
68
+ break
69
+ case "compaction":
70
+ flush()
71
+ out.push({ kind: "compaction", ...(t.at ? { at: t.at } : {}), summary: t.summary })
72
+ break
73
+ case "reasoning":
74
+ if (!opts.keepReasoning) {
75
+ drop("reasoning (no place for it)")
76
+ break
77
+ }
78
+ // Thinking comes before a response's text and calls; thinking after them starts the next response.
79
+ if (cur && (cur.text || cur.calls.length)) flush()
80
+ if (t.text.trim()) model(t.at).thoughts.push({ ...(t.at ? { at: t.at } : {}), text: t.text })
81
+ break
82
+ case "assistant": {
83
+ // Text after a tool call was written once the call's result came back: a new response.
84
+ if (cur?.calls.length) flush()
85
+ if (!t.text.trim()) break
86
+ const m = model(t.at)
87
+ m.text = m.text ? `${m.text}\n\n${t.text}` : t.text
88
+ if (t.model && !m.model) m.model = t.model
89
+ if (t.usage && !m.usage) m.usage = t.usage
90
+ break
91
+ }
92
+ case "tool_call": {
93
+ if (answered.has(t.id)) {
94
+ drop("repeated tool call ids")
95
+ break
96
+ }
97
+ const a: Answered = { call: t, edits: [] }
98
+ answered.set(t.id, a)
99
+ model(t.at).calls.push(a)
100
+ break
101
+ }
102
+ case "tool_result": {
103
+ const a = answered.get(t.callId)
104
+ if (!a) drop("tool results without a call")
105
+ else if (a.result) drop("repeated tool results")
106
+ else {
107
+ a.result = t
108
+ if (cur?.calls.includes(a)) flush()
109
+ }
110
+ break
111
+ }
112
+ case "file_edit": {
113
+ const a = t.callId ? answered.get(t.callId) : undefined
114
+ if (a) a.edits.push(t)
115
+ else drop("file edits not made by a tool call")
116
+ break
117
+ }
118
+ case "subagent":
119
+ drop("subagent sessions (their results stay in the parent's tool results)")
120
+ break
121
+ }
122
+ }
123
+ flush()
124
+ return out
125
+ }
126
+
127
+ /** A target tool: its name, and for each argument it takes, the names other tools give that argument. */
128
+ export interface ToolSpec {
129
+ name: string
130
+ /** The first argument is the one the call must have for the mapping to apply. */
131
+ args: Record<string, readonly string[]>
132
+ }
133
+
134
+ export const ALIASES = {
135
+ file: ["file_path", "path", "filePath", "absolute_path", "target_file", "file", "filename", "notebook_path"],
136
+ dir: ["dir_path", "path", "directory", "relative_workspace_path", "target_directory", "dir"],
137
+ oldText: ["old_string", "oldString", "old_str", "old_text"],
138
+ newText: ["new_string", "newString", "new_str", "new_text"],
139
+ content: ["content", "contents", "file_text", "text"],
140
+ command: ["command", "cmd", "commandLine"],
141
+ pattern: ["pattern", "query", "regex", "search", "glob_pattern"],
142
+ query: ["query", "search_term", "q"],
143
+ url: ["url", "uri"],
144
+ } as const
145
+
146
+ /** Function names Gemini, OpenAI-style APIs and these tools all accept. */
147
+ export const functionName = (name: string) => {
148
+ const v = name.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 64)
149
+ return /^[A-Za-z_]/.test(v) ? v : `_${v}`.slice(0, 64)
150
+ }
151
+
152
+ /**
153
+ * A portable call as the target's own tool: the target's name and argument names, with every argument the
154
+ * target has no name for kept as it was. A call whose main argument cannot be found keeps its own name and
155
+ * arguments, so nothing is guessed. Arguments must be an object; anything else is wrapped as {input}.
156
+ */
157
+ export function mapCall(call: Call, spec: ToolSpec | undefined): { name: string; args: Record<string, unknown> } {
158
+ const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Record<string, unknown>) : undefined
159
+ const own = { name: functionName(call.name), args: input ?? { input: call.input ?? null } }
160
+ if (!spec || !input) return own
161
+ const keys = Object.entries(spec.args)
162
+ const found = (aliases: readonly string[]) => aliases.find((k) => input[k] !== undefined && input[k] !== null)
163
+ if (!keys.length || !found(keys[0]![1])) return own
164
+ const args: Record<string, unknown> = {}
165
+ const used = new Set<string>()
166
+ for (const [key, aliases] of keys) {
167
+ const from = aliases.filter((k) => !used.has(k)).find((k) => input[k] !== undefined && input[k] !== null)
168
+ if (!from) continue
169
+ used.add(from)
170
+ const v = input[from]
171
+ // A command given as argv (Codex's shell) is one command line to these tools.
172
+ args[key] = key === "command" && Array.isArray(v) && v.every((x) => typeof x === "string") ? v.join(" ") : v
173
+ }
174
+ for (const [k, v] of Object.entries(input)) if (!used.has(k) && !(k in args)) args[k] = v
175
+ return { name: spec.name, args }
176
+ }
177
+
178
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
179
+ const IMAGE_LIMIT = 5 * 1024 * 1024
180
+
181
+ /** An attached image's bytes, when it is still on disk, a known image type, and small enough to inline. */
182
+ export function imageData(a: Attachment): { mimeType: string; data: string } | undefined {
183
+ if (a.kind !== "image" || !a.path) return undefined
184
+ const mimeType = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
185
+ if (!mimeType || !Object.values(IMAGE_TYPES).includes(mimeType)) return undefined
186
+ try {
187
+ if (statSync(a.path).size > IMAGE_LIMIT) return undefined
188
+ return { mimeType, data: readFileSync(a.path).toString("base64") }
189
+ } catch {
190
+ return undefined
191
+ }
192
+ }
193
+
194
+ /** The text a tool result carries into the target, with where its full output was kept when it was cut. */
195
+ export function resultOutput(r: Result | undefined): string {
196
+ if (!r) return "(no result was recorded for this call)"
197
+ const out = r.fullPath ? `${r.output}\n\n[Full output: ${r.fullPath}]` : r.output
198
+ return out.trim() ? out : "(no output)"
199
+ }
200
+
201
+ /** Timestamps that never go backwards, from the turns' own times where they have them. */
202
+ export function clock(session: Session, now: Date) {
203
+ const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
204
+ let t = (known ?? now.getTime()) - 1
205
+ return (at?: string) => {
206
+ const v = at ? Date.parse(at) : Number.NaN
207
+ t = Number.isFinite(v) && v > t ? v : t + 1
208
+ return new Date(t).toISOString()
209
+ }
210
+ }
211
+
212
+ /** A one-line title for the new session: the source's title, or its first request. */
213
+ export function titleOf(session: Session): string {
214
+ const first = session.turns.find((t) => t.kind === "user" && t.text.trim()) as Extract<Turn, { kind: "user" }> | undefined
215
+ return (session.header.title ?? first?.text ?? "Session").replace(/\s+/g, " ").trim().slice(0, 80) || "Session"
216
+ }
217
+
218
+ /** A folder as these tools record it: the resolved path the process sees (/tmp is /private/tmp on macOS). */
219
+ export function realCwd(cwd: string): string {
220
+ try {
221
+ return realpathSync(cwd)
222
+ } catch {
223
+ return resolve(cwd)
224
+ }
225
+ }
226
+
227
+ /** A word for a shell command line, quoted only when it needs it. */
228
+ export const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
@@ -0,0 +1,396 @@
1
+ import { createHash } from "node:crypto"
2
+ import { readFileSync } from "node:fs"
3
+ import { basename, dirname, join } from "node:path"
4
+ import { gitInfo, isDir, ls, mtime, readJson } from "../../fsutil.ts"
5
+ import type { Session, Turn } from "../../session/types.ts"
6
+ import { header, iso, lossCounter } from "../../session/util.ts"
7
+ import type { Reader, SessionRef, Writer } from "../types.ts"
8
+ import { closeOpenCalls, CONTINUED_NOTE, type Conversation, ignoredUserContent, LABEL, loadConversation, messagesToTurns, newCtx, type Part, partsToTurns, TOOL, toParts } from "./records.ts"
9
+ import { geminiNativeWriter } from "./writer.ts"
10
+
11
+ // Gemini CLI keeps each project's sessions under ~/.gemini/tmp/<project>/: chats/session-*.json (0.33 and
12
+ // older: one JSON document rewritten every turn) or chats/session-*.jsonl (current: an append-only log),
13
+ // subagent sessions in chats/<parent session id>/<id>.jsonl, and, when checkpointing is on,
14
+ // checkpoints/*.json snapshots of the API history taken before each file edit. Versions before chat
15
+ // recording left only those checkpoints, so a conversation they cover and no chat file does is read
16
+ // from them. <project> is a slug from ~/.gemini/projects.json now, the sha256 of the project folder
17
+ // before. The writer (writer.ts) continues a session as a new Gemini CLI session in the installed format.
18
+
19
+ const sha256 = (s: string) => createHash("sha256").update(s).digest("hex")
20
+ const isHash = (s: string) => /^[0-9a-f]{64}$/.test(s)
21
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
22
+ const titleFrom = (s: string | undefined) => (s && oneLine(s) ? oneLine(s).slice(0, 120) : undefined)
23
+
24
+ function readText(p: string): string | undefined {
25
+ try {
26
+ return readFileSync(p, "utf8")
27
+ } catch {
28
+ return undefined
29
+ }
30
+ }
31
+
32
+ /** Project folder by tmp folder name: registry slugs, .project_root files, and sha256 names of every known folder. */
33
+ function projectFolders(home: string): Map<string, string> {
34
+ const gemini = join(home, ".gemini")
35
+ const map = new Map<string, string>()
36
+ const reg = readJson<{ projects?: Record<string, unknown> }>(join(gemini, "projects.json"))?.projects
37
+ for (const [path, slug] of Object.entries(reg ?? {})) if (typeof slug === "string") map.set(slug, path)
38
+ for (const base of ["tmp", "history"]) {
39
+ for (const d of ls(join(gemini, base))) {
40
+ const root = readText(join(gemini, base, d, ".project_root"))?.trim()
41
+ if (root && !map.has(d)) map.set(d, root)
42
+ }
43
+ }
44
+ for (const root of [...map.values()]) map.set(sha256(root), root)
45
+ // Folders the person trusted, and the folders above them, may name a legacy sha256 folder too. A sha256
46
+ // match is exact, so extra candidates cannot give a wrong folder.
47
+ const trusted = readJson<Record<string, unknown>>(join(gemini, "trustedFolders.json"))
48
+ for (const path of trusted && typeof trusted === "object" && !Array.isArray(trusted) ? Object.keys(trusted) : []) {
49
+ for (let d = path; d.startsWith("/") && d !== "/"; d = dirname(d)) if (!map.has(sha256(d))) map.set(sha256(d), d)
50
+ }
51
+ return map
52
+ }
53
+
54
+ /** Absolute paths mentioned in tool arguments, to recover a legacy sha256-named project's folder. */
55
+ function absPaths(v: unknown, out: Set<string>, depth = 0): Set<string> {
56
+ if (out.size > 200 || depth > 10) return out
57
+ if (typeof v === "string") {
58
+ if (v.startsWith("/") && !v.includes("\n")) out.add(v)
59
+ } else if (Array.isArray(v)) for (const x of v) absPaths(x, out, depth + 1)
60
+ else if (v && typeof v === "object") for (const x of Object.values(v)) absPaths(x, out, depth + 1)
61
+ return out
62
+ }
63
+
64
+ /** The folder whose sha256 is `hash`, found among the ancestors of `paths`. */
65
+ function rootFromPaths(hash: string, paths: Iterable<string>): string | undefined {
66
+ for (const p of paths) {
67
+ for (let d = p; d && d !== "/" && d !== "."; d = dirname(d)) if (sha256(d) === hash) return d
68
+ }
69
+ return undefined
70
+ }
71
+
72
+ function projectCwd(folders: Map<string, string>, folder: string, projectHash: string | undefined, args: () => unknown): string | undefined {
73
+ return folders.get(folder) ?? (projectHash ? folders.get(projectHash) : undefined) ?? (isHash(folder) ? rootFromPaths(folder, absPaths(args(), new Set())) : undefined)
74
+ }
75
+
76
+ /** A user message's text, without the note brain puts first in a session it continued. */
77
+ const userText = (m: { content?: unknown }) =>
78
+ toParts(m.content as Part[])
79
+ .filter((p, i) => !(i === 0 && typeof p.text === "string" && CONTINUED_NOTE.test(p.text)))
80
+ .map((p) => (typeof p.text === "string" && !p.thought ? p.text : ""))
81
+ .join("")
82
+ /** A user message's text, unless Gemini CLI leaves the message out of the conversation. */
83
+ const askText = (m: { content?: unknown }) => (ignoredUserContent(m.content as Part[]) ? "" : userText(m))
84
+
85
+ interface Summary {
86
+ sessionId: string
87
+ kind?: string
88
+ projectHash?: string
89
+ startTime?: string
90
+ lastUpdated?: string
91
+ title?: string
92
+ /** Tool arguments, kept only for legacy files whose project folder must be recovered. */
93
+ args?: unknown
94
+ }
95
+
96
+ /**
97
+ * Enough of a session for the list. A legacy .json is one document, so it is parsed whole; a .jsonl is
98
+ * scanned for its metadata line, its {$set} records and its first user message only.
99
+ */
100
+ function summarize(path: string): Summary | undefined {
101
+ if (path.endsWith(".json")) {
102
+ const conv = loadConversation(path)?.conv
103
+ if (!conv || !conv.messages.some((m) => m.type === "user")) return undefined
104
+ const first = conv.messages.find((m) => m.type === "user" && askText(m).trim())
105
+ return {
106
+ sessionId: conv.sessionId,
107
+ kind: conv.kind,
108
+ projectHash: conv.projectHash,
109
+ startTime: conv.startTime,
110
+ lastUpdated: conv.lastUpdated,
111
+ title: titleFrom(conv.summary) ?? titleFrom(first && userText(first)),
112
+ args: conv.messages.map((m) => m.toolCalls?.map((t) => t.args)),
113
+ }
114
+ }
115
+ const text = readText(path)
116
+ if (!text) return undefined
117
+ let meta: Record<string, any> = {}
118
+ let first: string | undefined
119
+ let line0 = true
120
+ for (const line of text.split("\n")) {
121
+ if (!line.trim()) continue
122
+ const isFirst = line0
123
+ line0 = false
124
+ if (!isFirst && !line.startsWith('{"$set"') && (first !== undefined || !line.includes('"type":"user"'))) continue
125
+ try {
126
+ const r = JSON.parse(line)
127
+ if (isFirst && typeof r.sessionId === "string") meta = { ...meta, ...r }
128
+ else if (r.$set && typeof r.$set === "object") {
129
+ meta = { ...meta, ...r.$set }
130
+ if (first === undefined && Array.isArray(r.$set.messages)) first = r.$set.messages.map((m: any) => (m?.type === "user" ? askText(m) : "")).find((t: string) => t.trim())
131
+ } else if (r.type === "user" && askText(r).trim()) first = askText(r)
132
+ } catch {
133
+ // a half-written line; the full read will count it
134
+ }
135
+ }
136
+ if (typeof meta.sessionId !== "string" || first === undefined) return undefined
137
+ return { sessionId: meta.sessionId, kind: meta.kind, projectHash: meta.projectHash, startTime: meta.startTime, lastUpdated: meta.lastUpdated, title: titleFrom(meta.summary) ?? titleFrom(first) }
138
+ }
139
+
140
+ // ---- checkpoints ------------------------------------------------------------------------------------
141
+
142
+ interface Checkpoint {
143
+ path: string
144
+ at?: string
145
+ history: Array<{ role?: string; parts?: Part[] }>
146
+ /** The UI's history items; their ids are the epoch milliseconds they were shown. */
147
+ ui: Array<{ id?: unknown }>
148
+ args: unknown
149
+ }
150
+
151
+ /** "2025-07-28T15-52-02_821Z-file.ts-replace.json" was written at 2025-07-28T15:52:02.821Z. */
152
+ function checkpointTime(name: string): string | undefined {
153
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})_(\d{3})Z/)
154
+ return m ? `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z` : undefined
155
+ }
156
+
157
+ function readCheckpoint(path: string): Checkpoint | undefined {
158
+ const j = readJson<Record<string, any>>(path)
159
+ if (!j || !Array.isArray(j.clientHistory)) return undefined
160
+ return { path, at: checkpointTime(basename(path)), history: j.clientHistory, ui: Array.isArray(j.history) ? j.history : [], args: [j.toolCall?.args, j.filePath, j.clientHistory] }
161
+ }
162
+
163
+ /**
164
+ * Checkpoints grouped by conversation: each one holds the whole API history up to its edit, so a later
165
+ * checkpoint of the same conversation starts with an earlier one's history.
166
+ */
167
+ function checkpointGroups(dir: string): Checkpoint[][] {
168
+ const groups: Checkpoint[][] = []
169
+ const key = (c: unknown) => JSON.stringify(c)
170
+ for (const name of ls(dir).filter((n) => n.endsWith(".json")).sort()) {
171
+ const cp = readCheckpoint(join(dir, name))
172
+ if (!cp || !cp.history.length) continue
173
+ const g = groups.find((g) => {
174
+ const last = g[g.length - 1]!
175
+ return last.history.length <= cp.history.length && last.history.every((c, i) => key(c) === key(cp.history[i]))
176
+ })
177
+ if (g) g.push(cp)
178
+ else groups.push([cp])
179
+ }
180
+ return groups
181
+ }
182
+
183
+ /** Before chat recording, Gemini CLI opened every conversation with its own environment context and an acknowledgement. */
184
+ const isSetupContext = (c: { role?: string; parts?: Part[] } | undefined) =>
185
+ c?.role === "user" && (c.parts ?? []).some((p) => typeof p.text === "string" && p.text.startsWith("This is the Gemini CLI. We are setting up the context"))
186
+
187
+ function checkpointRefs(dir: string, folder: string, spans: Array<[number, number]>, folders: Map<string, string>): SessionRef[] {
188
+ const refs: SessionRef[] = []
189
+ for (const g of checkpointGroups(join(dir, "checkpoints"))) {
190
+ const first = Date.parse(g[0]!.at ?? "")
191
+ const last = Date.parse(g[g.length - 1]!.at ?? "")
192
+ // Newer versions record the same conversation in chats/ too; read it from there.
193
+ if (Number.isFinite(first) && spans.some(([s, e]) => first <= e && last >= s)) continue
194
+ const latest = g[g.length - 1]!
195
+ const title = titleFrom(firstAsk(latest.history))
196
+ const stamp = basename(g[0]!.path).match(/^[^Z]*Z/)?.[0] ?? basename(g[0]!.path, ".json")
197
+ const cwd = projectCwd(folders, folder, undefined, () => g.map((c) => c.args))
198
+ refs.push({
199
+ tool: TOOL,
200
+ sourceId: `checkpoint-${stamp}-${folder.slice(0, 8)}`,
201
+ path: latest.path,
202
+ ...(title ? { title } : {}),
203
+ ...(cwd ? { cwd } : {}),
204
+ ...(latest.at ? { updatedAt: latest.at } : {}),
205
+ extra: { folder, checkpoints: g.map((c) => c.path) },
206
+ })
207
+ }
208
+ return refs
209
+ }
210
+
211
+ /** The first thing the person asked in a checkpoint's history, after Gemini CLI's own setup exchange. */
212
+ const firstAsk = (history: Checkpoint["history"]) => {
213
+ const c = history.find((c, i) => c.role === "user" && !(i === 0 && isSetupContext(c)) && userText({ content: c.parts }).trim())
214
+ return c ? userText({ content: c.parts }) : undefined
215
+ }
216
+
217
+ function readCheckpointSession(ref: SessionRef, home: string): Session {
218
+ const files = (ref.extra?.checkpoints as string[] | undefined) ?? [ref.path]
219
+ const cps = files.map(readCheckpoint).filter((c): c is Checkpoint => Boolean(c))
220
+ const latest = cps[cps.length - 1]
221
+ if (!latest) throw new Error(`${ref.path} is not a Gemini CLI checkpoint`)
222
+ const shown = latest.ui.map((u) => (typeof u.id === "number" ? u.id : NaN)).filter(Number.isFinite)
223
+ const startedAt = shown.length ? iso(Math.min(...shown)) : cps[0]!.at
224
+ const folder = (ref.extra?.folder as string | undefined) ?? basename(dirname(dirname(latest.path)))
225
+ const cwd = ref.cwd ?? projectCwd(projectFolders(home), folder, undefined, () => cps.map((c) => c.args))
226
+ const title = ref.title ?? titleFrom(firstAsk(latest.history))
227
+ const h = header(TOOL, ref.sourceId, {
228
+ sourcePath: latest.path,
229
+ ...(title ? { title } : {}),
230
+ project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
231
+ ...(startedAt ? { startedAt } : {}),
232
+ ...(latest.at ? { updatedAt: latest.at } : {}),
233
+ })
234
+ const lost = lossCounter()
235
+ const ctx = newCtx(h, lost)
236
+ let history = latest.history
237
+ if (isSetupContext(history[0])) {
238
+ lost.add("Gemini CLI setup context")
239
+ history = history.slice(history[1]?.role === "model" ? 2 : 1)
240
+ }
241
+ let turns: Turn[] = []
242
+ for (const c of history) {
243
+ if (c.role !== "user" && c.role !== "model") lost.add(`unknown record types: ${String(c.role)}`)
244
+ else turns.push(...partsToTurns(c.parts, c.role, undefined, ctx))
245
+ }
246
+ // The newest checkpoint is taken just before its own edit runs, so that call, the last turn, has no
247
+ // result yet; an open call before it was cancelled or interrupted.
248
+ const final = turns[turns.length - 1]
249
+ turns = closeOpenCalls(turns, ctx.pair.open(), ctx, (c) => (c === final ? "the checkpoint was saved before this call ran" : "the call was cancelled or interrupted"))
250
+ lost.add("timestamps and model names (checkpoints keep neither)")
251
+ lost.flush(h)
252
+ return { header: h, turns }
253
+ }
254
+
255
+ // ---- chat sessions ----------------------------------------------------------------------------------
256
+
257
+ function toSession(conv: Conversation, path: string, cwd: string | undefined, extraLost: Array<[string, number]>, parent?: { id: string; sourceId: string }): Session {
258
+ const first = conv.messages.find((m) => m.type === "user" && askText(m).trim())
259
+ const title = titleFrom(conv.summary) ?? titleFrom(first && askText(first))
260
+ const startedAt = iso(conv.startTime)
261
+ const updatedAt = iso(conv.lastUpdated) ?? mtime(path)
262
+ const h = header(TOOL, parent?.sourceId ?? conv.sessionId, {
263
+ sourcePath: path,
264
+ ...(title ? { title } : {}),
265
+ project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
266
+ ...(startedAt ? { startedAt } : {}),
267
+ ...(updatedAt ? { updatedAt } : {}),
268
+ ...(parent ? { parent: { id: parent.id, kind: "subagent" as const } } : {}),
269
+ })
270
+ const lost = lossCounter()
271
+ for (const [what, n] of extraLost) lost.add(what, n)
272
+ const ctx = newCtx(h, lost)
273
+ const turns = closeOpenCalls(messagesToTurns(conv.messages, ctx), ctx.pair.open(), ctx, () => "the call never got a response")
274
+ lost.flush(h)
275
+ return { header: h, turns }
276
+ }
277
+
278
+ function load(path: string): { conv: Conversation; lost: Array<[string, number]> } {
279
+ const l = loadConversation(path)
280
+ if (!l) throw new Error(`${path} is not a Gemini CLI session file`)
281
+ const lost: Array<[string, number]> = [...l.unknown].map(([k, n]) => [`unknown record types: ${k}`, n])
282
+ if (l.bad) lost.push(["unreadable lines", l.bad])
283
+ return { conv: l.conv, lost }
284
+ }
285
+
286
+ /**
287
+ * A session's subagents, each placed where it started among the parent's turns. Current versions keep
288
+ * them in chats/<parent session id>/<id>.jsonl; 0.33 wrote them beside the parent as session-*.json with
289
+ * the parent's session id and kind "subagent".
290
+ */
291
+ function withChildren(s: Session, path: string, cwd: string | undefined): Session {
292
+ const chats = dirname(path)
293
+ const id = s.header.sourceId
294
+ const files = [
295
+ ...ls(join(chats, id)).filter((n) => /\.jsonl?$/.test(n)).map((n) => join(chats, id, n)),
296
+ ...ls(chats).filter((n) => /^session-.*\.json$/.test(n) && join(chats, n) !== path).map((n) => join(chats, n)),
297
+ ].sort()
298
+ const children: Session[] = []
299
+ for (const file of files) {
300
+ const nested = dirname(file) !== chats
301
+ try {
302
+ const { conv, lost } = load(file)
303
+ if (!nested && (conv.sessionId !== id || conv.kind !== "subagent")) continue
304
+ const sourceId = nested ? conv.sessionId : `${id}/${basename(file).replace(/\.json$/, "")}`
305
+ children.push(toSession(conv, file, cwd, lost, { id: s.header.id, sourceId }))
306
+ } catch {
307
+ // A file beside the session that cannot be read may be any session of the project, not this one's.
308
+ if (nested) s.header.lost.push(`unreadable subagent session (${basename(file)})`)
309
+ }
310
+ }
311
+ if (!children.length) return s
312
+ const turns = [...s.turns]
313
+ for (const c of children) {
314
+ const at = Date.parse(c.header.startedAt ?? "")
315
+ const end = Date.parse(c.header.updatedAt ?? "")
316
+ let i = Number.isFinite(at) ? turns.findIndex((x) => x.at !== undefined && Date.parse(x.at) > at) : -1
317
+ // A tool call's time is when it finished, so the call that ran the subagent is timed after the
318
+ // subagent's last message: among the tool turns found by time, the first call that ended after the
319
+ // subagent did is its call, and the subagent goes after that call's result and edits.
320
+ let callId: string | undefined
321
+ for (let j = i; j >= 0 && j < turns.length && Number.isFinite(end) && /^(tool_call|tool_result|file_edit|subagent)$/.test(turns[j]!.kind); j++) {
322
+ const x = turns[j]!
323
+ if (x.kind === "tool_call" && x.at && Date.parse(x.at) >= end) {
324
+ callId = x.id
325
+ i = j + 1
326
+ while (i < turns.length && (turns[i]!.kind === "tool_result" || turns[i]!.kind === "file_edit") && (turns[i] as { callId?: string }).callId === callId) i++
327
+ break
328
+ }
329
+ }
330
+ // Never between a call and its result.
331
+ while (i >= 0 && i < turns.length && turns[i]!.kind === "tool_result") i++
332
+ const t: Turn = { kind: "subagent", ...(c.header.startedAt ? { at: c.header.startedAt } : {}), sessionId: c.header.id, ...(callId ? { callId } : {}), ...(c.header.title ? { description: c.header.title } : {}) }
333
+ if (i < 0 || i >= turns.length) turns.push(t)
334
+ else turns.splice(i, 0, t)
335
+ }
336
+ return { ...s, turns, children }
337
+ }
338
+
339
+ /** Which file stands for a session id: a main session over a subagent, the live .jsonl over its legacy .json, then the newest. */
340
+ function rank(r: SessionRef): string {
341
+ return `${r.extra?.subagent ? 0 : 1}${r.path.endsWith(".jsonl") ? 1 : 0}${r.updatedAt ?? ""}`
342
+ }
343
+
344
+ export const reader: Reader = {
345
+ tool: TOOL,
346
+ label: LABEL,
347
+
348
+ async list(home) {
349
+ const tmp = join(home, ".gemini", "tmp")
350
+ if (!isDir(tmp)) return []
351
+ const folders = projectFolders(home)
352
+ const refs: SessionRef[] = []
353
+ for (const folder of ls(tmp)) {
354
+ const dir = join(tmp, folder)
355
+ if (!isDir(dir)) continue
356
+ const chats = join(dir, "chats")
357
+ const byId = new Map<string, SessionRef>()
358
+ const spans: Array<[number, number]> = []
359
+ for (const name of ls(chats)) {
360
+ if (!/^session-.*\.jsonl?$/.test(name)) continue
361
+ const path = join(chats, name)
362
+ const s = summarize(path)
363
+ if (!s) continue
364
+ const updatedAt = iso(s.lastUpdated) ?? mtime(path)
365
+ spans.push([Date.parse(iso(s.startTime) ?? updatedAt ?? ""), Date.parse(updatedAt ?? "")])
366
+ const cwd = projectCwd(folders, folder, s.projectHash, () => s.args)
367
+ const ref: SessionRef = {
368
+ tool: TOOL,
369
+ sourceId: s.sessionId,
370
+ path,
371
+ ...(s.title ? { title: s.title } : {}),
372
+ ...(cwd ? { cwd } : {}),
373
+ ...(updatedAt ? { updatedAt } : {}),
374
+ extra: { folder, ...(s.kind === "subagent" ? { subagent: true } : {}) },
375
+ }
376
+ // One id can have several files: 0.33's subagents share their parent's id, and resuming a legacy
377
+ // .json writes a .jsonl copy beside it. The main, live file stands for the session.
378
+ const prev = byId.get(s.sessionId)
379
+ if (!prev || rank(ref) > rank(prev)) byId.set(s.sessionId, ref)
380
+ }
381
+ refs.push(...byId.values(), ...checkpointRefs(dir, folder, spans, folders))
382
+ }
383
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
384
+ },
385
+
386
+ async read(ref, home) {
387
+ if (ref.extra?.checkpoints) return readCheckpointSession(ref, home)
388
+ const { conv, lost } = load(ref.path)
389
+ const folder = (ref.extra?.folder as string | undefined) ?? basename(dirname(dirname(ref.path)))
390
+ const cwd = ref.cwd ?? projectCwd(projectFolders(home), folder, conv.projectHash, () => conv.messages.map((m) => m.toolCalls?.map((t) => t.args)))
391
+ return withChildren(toSession(conv, ref.path, cwd, lost), ref.path, cwd)
392
+ },
393
+ }
394
+
395
+ export const readers: Reader[] = [reader]
396
+ export const writers: Writer[] = [geminiNativeWriter]