@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,354 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { basename, dirname, join, resolve } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, isDir, ls, readJson, versionOf } from "../../fsutil.ts"
6
+ import type { Session } from "../../session/types.ts"
7
+ import { countTurns, header, lossCounter, unpaired } from "../../session/util.ts"
8
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
9
+ import { ALIASES, type Call, clock, drops, imageData, mapCall, realCwd, regroup, resultOutput, shellQuote, titleOf, type ToolSpec } from "./exchanges.ts"
10
+ import { closeOpenCalls, continuedNote, LABEL, loadConversation, type MessageRecord, messagesToTurns, newCtx, type Part, SNAPSHOT_ACK, snapshotOf, TOOL } from "./records.ts"
11
+
12
+ // Continues a portable session in Gemini CLI as a new session of its own: a chat file in the project's
13
+ // folder, ~/.gemini/tmp/<project>/chats/, in the format the installed version loads. Before 0.39.0 that is
14
+ // one ConversationRecord JSON document (session-<time>-<id8>.json); from 0.39.0 it is the append-only JSONL
15
+ // log (session-<time>-<id8>.jsonl: a metadata line, one line per message, {$set} for the summary). Checked
16
+ // against 0.33.1 (installed here) and 0.67.0: chatRecordingService and sessionUtils (core) and the CLI's
17
+ // sessionUtils, which list a chat file, and convertSessionToClientHistory, which replays it to the model.
18
+ //
19
+ // <project> is the short name Gemini CLI's ProjectRegistry gives the folder (~/.gemini/projects.json and
20
+ // .project_root markers in tmp/ and history/); the writer finds or claims it the same way. A project still
21
+ // in its pre-registry folder (tmp/<sha256 of the folder>) is written there: Gemini CLI copies that folder to
22
+ // the new name on its next start, and a new name holding chats already would stop that copy.
23
+ //
24
+ // Messages are written in the shape Gemini CLI has always recorded and still replays: user messages with
25
+ // Part[] content; one gemini message per model response, with its text, its readable thoughts (thoughts[],
26
+ // which the UI shows and 0.67 sends back as thought parts) and its tool calls, each holding its own
27
+ // functionResponse result. Tool names map to Gemini CLI's own where a call's arguments allow it. The note
28
+ // on where the session came from is the first part of the first user message; displayContent leaves it
29
+ // out, so the model reads it and the screen shows the person's words. A compacted session starts from its
30
+ // last summary, written as Gemini CLI's compression writes one (a <state_snapshot> user message and its
31
+ // acknowledgement). `summary` names the session in the list (and stops Gemini CLI asking a model for one).
32
+
33
+ /** Versions this writer was checked against; others get the brief. */
34
+ export const TESTED_VERSIONS = ["0.33.1", "0.67.0"]
35
+
36
+ /** Gemini CLI wrote whole-file JSON sessions before 0.39.0 and append-only JSONL from it on. */
37
+ export function formatFor(version: string): "json" | "jsonl" {
38
+ const [a = 0, b = 0] = version.split(".").map(Number)
39
+ return a > 0 || b >= 39 ? "jsonl" : "json"
40
+ }
41
+
42
+ /** The installed Gemini CLI version ("0.33.1"), or undefined when it is not installed. */
43
+ export const geminiVersion = (bin = "gemini") => versionOf(bin)?.match(/(\d+\.\d+\.\d+)/)?.[1]
44
+
45
+ const sha256 = (s: string) => createHash("sha256").update(s).digest("hex")
46
+
47
+ /** Gemini CLI's own tool for a portable call, by what the call does. */
48
+ function geminiTool(call: Call): ToolSpec | undefined {
49
+ const input = (call.input ?? {}) as Record<string, unknown>
50
+ switch (call.toolKind) {
51
+ case "read":
52
+ return { name: "read_file", args: { file_path: ALIASES.file } }
53
+ case "edit":
54
+ return { name: "replace", args: { file_path: ALIASES.file, old_string: ALIASES.oldText, new_string: ALIASES.newText } }
55
+ case "write":
56
+ return { name: "write_file", args: { file_path: ALIASES.file, content: ALIASES.content } }
57
+ case "shell":
58
+ return { name: "run_shell_command", args: { command: ALIASES.command } }
59
+ case "search":
60
+ return { name: /glob/i.test(call.name) ? "glob" : "grep_search", args: { pattern: ALIASES.pattern, dir_path: ALIASES.dir } }
61
+ case "list":
62
+ return { name: "list_directory", args: { dir_path: ALIASES.dir } }
63
+ case "web":
64
+ return ALIASES.url.some((k) => input[k] !== undefined)
65
+ ? { name: "web_fetch", args: { prompt: ["prompt", ...ALIASES.url] } }
66
+ : { name: "google_web_search", args: { query: ALIASES.query } }
67
+ case "todo":
68
+ return { name: "write_todos", args: { todos: ["todos"] } }
69
+ default:
70
+ return undefined
71
+ }
72
+ }
73
+
74
+ export interface ConversationPlan {
75
+ /** The ConversationRecord, as the whole-file format stores it. */
76
+ record: { sessionId: string; projectHash: string; startTime: string; lastUpdated: string; messages: MessageRecord[]; kind: "main"; summary: string }
77
+ lost: string[]
78
+ /** What a read-back must find. */
79
+ expect: { users: number; assistants: number; calls: number }
80
+ }
81
+
82
+ /** The new session `sessionId` continuing `session`. Pure apart from reading attached images. */
83
+ export function planConversation(session: Session, opts: { sessionId: string; projectHash: string; now: Date; version: string }): ConversationPlan {
84
+ const { drop, list } = drops()
85
+ const exchanges = regroup(session, { fromLastCompaction: true, keepReasoning: true, drop })
86
+ const label = toolLabel(session.header.tool)
87
+ const stamp = clock(session, opts.now)
88
+ const expect = { users: 0, assistants: 0, calls: 0 }
89
+ const jsonl = formatFor(opts.version) === "jsonl"
90
+ let lastModel = session.header.models[session.header.models.length - 1] ?? "unknown"
91
+ // Call ids as Gemini CLI makes them when the API gives none: `<name>_<ms>_<n>`, prefixed `<name>__` from 0.4x on.
92
+ let n = 0
93
+ const callId = (name: string, at: string) => {
94
+ const base = `${name}_${Date.parse(at)}_${n++}`
95
+ return jsonl ? `${name}__${base}` : base
96
+ }
97
+ const note: Part = { text: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)) }
98
+ let noted = false
99
+ const messages: MessageRecord[] = []
100
+ const user = (at: string | undefined, parts: Part[]) => {
101
+ const m: MessageRecord & { displayContent?: Part[] } = { id: randomUUID(), timestamp: stamp(at), type: "user", content: noted ? parts : [note, ...parts] }
102
+ if (!noted && parts.length) m.displayContent = parts
103
+ noted = true
104
+ messages.push(m)
105
+ }
106
+ const gemini = (at: string | undefined, fields: Partial<MessageRecord>) => {
107
+ const m: MessageRecord = { id: randomUUID(), timestamp: stamp(at), type: "gemini", content: "", thoughts: [], tokens: null, model: lastModel, ...fields }
108
+ messages.push(m)
109
+ return m
110
+ }
111
+
112
+ // A session that opens with the model gets the note on its own first.
113
+ if (exchanges[0] && exchanges[0].kind === "model") user(exchanges[0].at, [])
114
+ exchanges.forEach((x, i) => {
115
+ if (x.kind === "user") {
116
+ let text = x.text
117
+ const images: Part[] = []
118
+ for (const a of x.attachments) {
119
+ if (a.kind === "file") {
120
+ if (a.path) text += `\n\n[Attached file: ${a.path}]`
121
+ continue
122
+ }
123
+ const img = imageData(a)
124
+ if (img) images.push({ inlineData: img })
125
+ else if (a.path) drop("images that could not be attached")
126
+ }
127
+ const parts: Part[] = [...(text.trim() ? [{ text }] : []), ...images]
128
+ if (!parts.length) return
129
+ user(x.at, parts)
130
+ if (text.trim()) expect.users++
131
+ } else if (x.kind === "compaction") {
132
+ user(x.at, [{ text: snapshotOf(label, x.summary) }])
133
+ // Two model messages side by side would reach the model as two model turns; the response itself follows.
134
+ if (exchanges[i + 1]?.kind !== "model") gemini(x.at, { content: SNAPSHOT_ACK })
135
+ } else {
136
+ if (x.model) lastModel = x.model
137
+ const m = gemini(x.at, { content: x.text, model: lastModel })
138
+ if (x.text.trim()) expect.assistants++
139
+ // Thought summaries are {subject, description}; brain's reasoning text is the description.
140
+ m.thoughts = x.thoughts.map((t) => ({ subject: "", description: t.text, timestamp: stamp(t.at) }))
141
+ if (x.usage) {
142
+ // Gemini's input count includes the cached part.
143
+ const input = (x.usage.input ?? 0) + (x.usage.cacheRead ?? 0)
144
+ const output = x.usage.output ?? 0
145
+ m.tokens = { input, output, cached: x.usage.cacheRead ?? 0, thoughts: 0, tool: 0, total: input + output } as MessageRecord["tokens"]
146
+ }
147
+ if (x.calls.length) {
148
+ m.toolCalls = x.calls.map((a) => {
149
+ const { name, args } = mapCall(a.call, geminiTool(a.call))
150
+ const at = stamp(a.result?.at ?? a.call.at)
151
+ const id = callId(name, at)
152
+ if (!a.result) drop("tool calls without a result (closed as errors)")
153
+ if (a.edits.length) drop("file edit diffs (Gemini CLI keeps one only with the whole file before and after)", a.edits.length)
154
+ const ok = a.result?.ok ?? false
155
+ const output = resultOutput(a.result)
156
+ expect.calls++
157
+ return {
158
+ id,
159
+ name,
160
+ args,
161
+ result: [{ functionResponse: { id, name, response: ok ? { output } : { error: output } } }],
162
+ status: ok ? "success" : "error",
163
+ timestamp: at,
164
+ resultDisplay: output,
165
+ displayName: name,
166
+ description: "",
167
+ renderOutputAsMarkdown: false,
168
+ }
169
+ })
170
+ }
171
+ }
172
+ })
173
+ if (!messages.length) throw new Error("nothing Gemini CLI can resume: the session has no messages")
174
+ return {
175
+ record: {
176
+ sessionId: opts.sessionId,
177
+ projectHash: opts.projectHash,
178
+ startTime: messages[0]!.timestamp!,
179
+ lastUpdated: opts.now.toISOString(),
180
+ messages,
181
+ kind: "main",
182
+ summary: `${titleOf(session)} (from ${label})`,
183
+ },
184
+ lost: list(),
185
+ expect,
186
+ }
187
+ }
188
+
189
+ /** The file's text in the format the version loads. */
190
+ export function serialize(plan: ConversationPlan, format: "json" | "jsonl"): string {
191
+ if (format === "json") return JSON.stringify(plan.record, null, 2)
192
+ const { messages, summary, ...meta } = plan.record
193
+ return `${[meta, ...messages, { $set: { summary } }].map((r) => JSON.stringify(r)).join("\n")}\n`
194
+ }
195
+
196
+ // ---- the project's folder ---------------------------------------------------------------------------
197
+
198
+ const readText = (p: string) => {
199
+ try {
200
+ return readFileSync(p, "utf8")
201
+ } catch {
202
+ return undefined
203
+ }
204
+ }
205
+
206
+ const slugify = (text: string) =>
207
+ text
208
+ .toLowerCase()
209
+ .replace(/[^a-z0-9]/g, "-")
210
+ .replace(/-+/g, "-")
211
+ .replace(/^-|-$/g, "") || "project"
212
+
213
+ export interface ProjectFolder {
214
+ /** tmp/<name>: where the chats folder goes. */
215
+ dir: string
216
+ /** .project_root markers to create, by path. */
217
+ markers: string[]
218
+ /** The registry entry to add, when the folder has none. */
219
+ register?: { path: string; slug: string }
220
+ }
221
+
222
+ /**
223
+ * The project's folder under ~/.gemini/tmp, as Gemini CLI's ProjectRegistry.getShortId finds or claims it:
224
+ * the registry's name when its markers agree, else a folder whose .project_root names the project, else a
225
+ * new name from the folder's base name (name, name-1, ...) that no registry entry or marker owns.
226
+ */
227
+ export function projectFolder(home: string, cwd: string): ProjectFolder {
228
+ const gemini = join(home, ".gemini")
229
+ const project = resolve(cwd)
230
+ const tmp = join(gemini, "tmp")
231
+ const bases = [tmp, join(gemini, "history")]
232
+ const owner = (base: string, slug: string) => readText(join(base, slug, ".project_root"))?.trim()
233
+ const legacy = join(tmp, sha256(project))
234
+ const registry = readJson<{ projects?: Record<string, string> }>(join(gemini, "projects.json"))?.projects ?? {}
235
+ let slug: string | undefined = registry[project]
236
+ if (slug && bases.some((b) => owner(b, slug!) !== undefined && resolve(owner(b, slug!)!) !== project)) slug = undefined
237
+ for (const base of bases) for (const d of slug ? [] : ls(base)) if (!slug && owner(base, d) !== undefined && resolve(owner(base, d)!) === project) slug = d
238
+ if (!slug) {
239
+ const taken = new Set(Object.values(registry))
240
+ const root = slugify(basename(project) || "project")
241
+ for (let i = 0; !slug; i++) {
242
+ const c = i === 0 ? root : `${root}-${i}`
243
+ if (!taken.has(c) && bases.every((b) => owner(b, c) === undefined || resolve(owner(b, c)!) === project)) slug = c
244
+ }
245
+ }
246
+ // Gemini CLI copies a pre-registry folder to the new name only while that name holds nothing but its marker.
247
+ const fresh = ls(join(tmp, slug)).every((f) => f === ".project_root")
248
+ if (isDir(legacy) && fresh) return { dir: legacy, markers: [] }
249
+ return {
250
+ dir: join(tmp, slug),
251
+ markers: bases.map((b) => join(b, slug!, ".project_root")).filter((p) => !exists(p)),
252
+ ...(registry[project] === slug ? {} : { register: { path: project, slug } }),
253
+ }
254
+ }
255
+
256
+ /** Adds the registry entry under Gemini CLI's own lock (proper-lockfile's `<file>.lock` folder), atomically. */
257
+ function register(home: string, entry: { path: string; slug: string }) {
258
+ const file = join(home, ".gemini", "projects.json")
259
+ const lock = `${file}.lock`
260
+ mkdirSync(join(home, ".gemini"), { recursive: true })
261
+ for (let tries = 0; ; tries++) {
262
+ try {
263
+ mkdirSync(lock)
264
+ break
265
+ } catch {
266
+ // A lock Gemini CLI left behind goes stale after 10 s, as proper-lockfile treats it.
267
+ let age = 0
268
+ try {
269
+ age = Date.now() - statSync(lock).mtimeMs
270
+ } catch {}
271
+ if (age > 10_000) rmdirSync(lock)
272
+ else if (tries > 50) throw new Error(`Gemini CLI is updating ${file}; try again`)
273
+ else Bun.sleepSync(100)
274
+ }
275
+ }
276
+ try {
277
+ const data = readJson<{ projects?: Record<string, string> }>(file) ?? {}
278
+ const next = { ...data, projects: { ...(data.projects ?? {}), [entry.path]: entry.slug } }
279
+ const tmp = `${file}.${randomUUID()}.tmp`
280
+ writeFileSync(tmp, JSON.stringify(next, null, 2))
281
+ renameSync(tmp, file)
282
+ } finally {
283
+ rmdirSync(lock)
284
+ }
285
+ }
286
+
287
+ // ---- the writer -------------------------------------------------------------------------------------
288
+
289
+ /** The Gemini CLI writer; `version` and `bin` are for tests, which must not depend on the installed binary. */
290
+ export function geminiWriter(opts: { bin?: string; version?: string } = {}): Writer {
291
+ const installed = () => opts.version ?? geminiVersion(opts.bin)
292
+ return {
293
+ tool: TOOL,
294
+ label: LABEL,
295
+ way: "native",
296
+
297
+ async ready(w: WriteOptions): Promise<Readiness> {
298
+ const v = installed()
299
+ if (!v) return { ok: false, why: "Gemini CLI is not installed (`gemini --version` did not run)" }
300
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Gemini CLI ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
301
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Gemini CLI lists and resumes sessions per project folder` }
302
+ return { ok: true }
303
+ },
304
+
305
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
306
+ const version = installed() ?? TESTED_VERSIONS[TESTED_VERSIONS.length - 1]!
307
+ const format = formatFor(version)
308
+ const cwd = realCwd(w.cwd)
309
+ const now = new Date()
310
+ const folder = projectFolder(w.home, cwd)
311
+ const chats = join(folder.dir, "chats")
312
+ let sessionId = randomUUID()
313
+ const name = () => `session-${now.toISOString().slice(0, 16).replace(/:/g, "-")}-${sessionId.slice(0, 8)}.${format}`
314
+ while (exists(join(chats, name()))) sessionId = randomUUID()
315
+ const path = join(chats, name())
316
+ const plan = planConversation(session, { sessionId, projectHash: sha256(cwd), now, version })
317
+ const written = [path, ...folder.markers, ...(folder.register ? [join(w.home, ".gemini", "projects.json")] : [])]
318
+ if (!w.dryRun) {
319
+ for (const m of folder.markers) {
320
+ mkdirSync(dirname(m), { recursive: true })
321
+ // "wx", as Gemini CLI claims a name: a marker that appeared meanwhile is left as it is.
322
+ writeFileSync(m, resolve(cwd), { flag: "wx" })
323
+ }
324
+ if (folder.register) register(w.home, folder.register)
325
+ mkdirSync(chats, { recursive: true })
326
+ // "wx": a new file only; an existing session is never touched.
327
+ writeFileSync(path, serialize(plan, format), { flag: "wx" })
328
+ const back = loadConversation(path)
329
+ const h = header(TOOL, sessionId)
330
+ const ctx = newCtx(h, lossCounter())
331
+ const turns = back ? closeOpenCalls(messagesToTurns(back.conv.messages, ctx), ctx.pair.open(), ctx, () => "") : []
332
+ const got = countTurns({ header: h, turns })
333
+ const pairs = unpaired({ header: h, turns })
334
+ if (!back || got.user !== plan.expect.users || got.assistant !== plan.expect.assistants || got.tool_call !== plan.expect.calls || ctx.pair.open().length || pairs.calls.length || pairs.results.length) {
335
+ rmSync(path, { force: true })
336
+ throw new Error(
337
+ `the new Gemini CLI session did not read back as written (users ${got.user}/${plan.expect.users}, replies ${got.assistant}/${plan.expect.assistants}, tool calls ${got.tool_call}/${plan.expect.calls}); nothing was kept`,
338
+ )
339
+ }
340
+ }
341
+ return {
342
+ tool: TOOL,
343
+ way: "native",
344
+ sessionId,
345
+ written,
346
+ open: `cd ${shellQuote(cwd)} && gemini --resume ${sessionId}`,
347
+ lost: plan.lost,
348
+ dryRun: !!w.dryRun,
349
+ }
350
+ },
351
+ }
352
+ }
353
+
354
+ export const geminiNativeWriter = geminiWriter()
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { gooseReader } from "./reader.ts"
3
+ import { gooseImportWriter } from "./writer.ts"
4
+
5
+ // Goose (AAIF, formerly Block): reads sessions.db and pre-1.10 JSONL sessions, and continues any session
6
+ // through Goose's own `session import`.
7
+ export const readers: Reader[] = [gooseReader]
8
+ export const writers: Writer[] = [gooseImportWriter]
@@ -0,0 +1,138 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { basename, join } from "node:path"
3
+ import { exists, gitInfo, ls, mtime, readJsonl } from "../../fsutil.ts"
4
+ import { withSnapshot } from "../../sqlite.ts"
5
+ import type { Session } from "../../session/types.ts"
6
+ import { header, iso, noteModel } from "../../session/util.ts"
7
+ import type { Reader, SessionRef } from "../types.ts"
8
+ import { type GooseMessage, LABEL, TOOL, dbPath, sessionsDir, turnsOf } from "./records.ts"
9
+
10
+ // Goose: sessions in ~/.local/share/goose/sessions/sessions.db (read from a copy, with its write-ahead log),
11
+ // or legacy <id>.jsonl files there when Goose has never created the database. Only the session types
12
+ // Goose's own `session list` shows are listed (user and scheduled), and only those with messages.
13
+
14
+ /** SQLite's CURRENT_TIMESTAMP text ("2026-11-08 05:18:00") is UTC without a zone. */
15
+ const sqlTime = (v: unknown) => (typeof v === "string" && /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/.test(v) ? iso(`${v.replace(" ", "T")}Z`) : iso(v))
16
+
17
+ const modelOf = (json: unknown): string | undefined => {
18
+ if (typeof json !== "string") return undefined
19
+ try {
20
+ const m = JSON.parse(json) as { model_name?: unknown }
21
+ return typeof m.model_name === "string" ? m.model_name : undefined
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ export function listDb(d: Database, path: string): SessionRef[] {
28
+ const rows = d
29
+ .query(
30
+ `SELECT s.id, s.name, s.working_dir, s.updated_at, MAX(m.created_timestamp) AS last
31
+ FROM sessions s JOIN messages m ON m.session_id = s.id
32
+ WHERE s.session_type IN ('user', 'scheduled')
33
+ GROUP BY s.id`,
34
+ )
35
+ .all() as Array<Record<string, any>>
36
+ return rows.map((r) => {
37
+ const updatedAt = iso(Number(r.last)) ?? sqlTime(r.updated_at)
38
+ return {
39
+ tool: TOOL,
40
+ sourceId: String(r.id),
41
+ path,
42
+ ...(r.name ? { title: String(r.name) } : {}),
43
+ ...(r.working_dir ? { cwd: String(r.working_dir) } : {}),
44
+ ...(updatedAt ? { updatedAt } : {}),
45
+ extra: { source: "db" },
46
+ }
47
+ })
48
+ }
49
+
50
+ export function readDb(d: Database, path: string, id: string, home: string): Session {
51
+ const s = d.query("SELECT * FROM sessions WHERE id = ?").get(id) as Record<string, any> | null
52
+ if (!s) throw new Error(`no Goose session ${id} in ${path}`)
53
+ const rows = d
54
+ .query("SELECT role, content_json, created_timestamp, metadata_json FROM messages WHERE session_id = ? ORDER BY created_timestamp, id")
55
+ .all(id) as Array<Record<string, any>>
56
+ const messages: GooseMessage[] = []
57
+ let bad = 0
58
+ for (const r of rows) {
59
+ try {
60
+ messages.push({ role: String(r.role), created: Number(r.created_timestamp), content: JSON.parse(String(r.content_json)), ...(r.metadata_json ? { metadata: JSON.parse(String(r.metadata_json)) } : {}) })
61
+ } catch {
62
+ bad++
63
+ }
64
+ }
65
+ const cwd = s.working_dir ? String(s.working_dir) : undefined
66
+ const h = header(TOOL, id, {
67
+ sourcePath: path,
68
+ ...(s.name ? { title: String(s.name) } : {}),
69
+ project: cwd ? { cwd, ...gitInfo(cwd, home) } : {},
70
+ ...(sqlTime(s.created_at) ? { startedAt: sqlTime(s.created_at)! } : {}),
71
+ })
72
+ noteModel(h, modelOf(s.model_config_json))
73
+ if (s.parent_session_id) h.parent = { id: `${TOOL}:${s.parent_session_id}`, kind: "fork" }
74
+ return finish(h, messages, bad)
75
+ }
76
+
77
+ function finish(h: Session["header"], messages: GooseMessage[], bad: number): Session {
78
+ const { turns, continued } = turnsOf(messages, h)
79
+ if (bad) h.lost.push(`unreadable messages (${bad})`)
80
+ if (continued) h.parent = { id: continued, kind: "continued" }
81
+ const times = messages.map((m) => iso(m.created)).filter((t): t is string => !!t)
82
+ if (!h.startedAt && times[0]) h.startedAt = times[0]
83
+ if (times.length) h.updatedAt = times[times.length - 1]
84
+ return { header: h, turns }
85
+ }
86
+
87
+ /** A pre-1.10 session file: a metadata line, then one message per line. */
88
+ function readLegacy(path: string, home: string): Session {
89
+ const { rows, bad } = readJsonl(path)
90
+ if (!rows.length) throw new Error(`${path} has no readable lines`)
91
+ const [meta, ...messages] = rows
92
+ const cwd = typeof meta!.working_dir === "string" && meta!.working_dir ? meta!.working_dir : undefined
93
+ const h = header(TOOL, basename(path, ".jsonl"), {
94
+ sourcePath: path,
95
+ ...(typeof meta!.description === "string" && meta!.description ? { title: meta!.description } : {}),
96
+ project: cwd ? { cwd, ...gitInfo(cwd, home) } : {},
97
+ })
98
+ return finish(h, messages as unknown as GooseMessage[], bad)
99
+ }
100
+
101
+ export const gooseReader: Reader = {
102
+ tool: TOOL,
103
+ label: LABEL,
104
+ async list(home) {
105
+ const db = dbPath(home)
106
+ if (exists(db)) {
107
+ try {
108
+ return withSnapshot(db, (d) => listDb(d, db)).sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
109
+ } catch {
110
+ return []
111
+ }
112
+ }
113
+ const refs: SessionRef[] = []
114
+ for (const name of ls(sessionsDir(home))) {
115
+ if (!name.endsWith(".jsonl")) continue
116
+ const path = join(sessionsDir(home), name)
117
+ const { rows } = readJsonl(path)
118
+ const meta = rows[0]
119
+ if (!meta || rows.length < 2) continue
120
+ const updatedAt = mtime(path)
121
+ refs.push({
122
+ tool: TOOL,
123
+ sourceId: basename(name, ".jsonl"),
124
+ path,
125
+ ...(typeof meta.description === "string" && meta.description ? { title: meta.description } : {}),
126
+ ...(typeof meta.working_dir === "string" && meta.working_dir ? { cwd: meta.working_dir } : {}),
127
+ ...(updatedAt ? { updatedAt } : {}),
128
+ extra: { source: "jsonl" },
129
+ })
130
+ }
131
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
132
+ },
133
+ async read(ref, home) {
134
+ if (!exists(ref.path)) throw new Error(`Goose session store not found: ${ref.path}`)
135
+ if (ref.path.endsWith(".jsonl")) return readLegacy(ref.path, home)
136
+ return withSnapshot(ref.path, (d) => readDb(d, ref.path, ref.sourceId, home))
137
+ },
138
+ }