@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,353 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
+ import { basename, join } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, gitInfo, isDir, versionOf } from "../../fsutil.ts"
6
+ import type { Session, Turn } from "../../session/types.ts"
7
+ import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
8
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
9
+ import { LABEL, TOOL, TESTED_EVENT_VERSION } from "./records.ts"
10
+ import { reader } from "./reader.ts"
11
+
12
+ // Continues a portable session in the Copilot CLI as a new session: ~/.copilot/session-state/<uuid>/ with
13
+ // events.jsonl, workspace.yaml and checkpoints/index.md, which `copilot --resume <uuid>` opens.
14
+ //
15
+ // The records are the ones Copilot CLI 1.0.107 writes itself when it imports a session
16
+ // (`copilot sessions import`, checked on a real run): session.start, then user.message, assistant.message
17
+ // with toolRequests, one tool.execution_start per call and a tool.execution_complete per result, every
18
+ // event on one parentId chain with uuid ids. Failed results carry error.message, as Copilot writes them.
19
+ // Calls become Copilot's own tools (view, edit, create, bash, grep, glob) where their arguments fit.
20
+ // Its own resume loads such a session offline (session.list and session.resume over the headless server).
21
+ //
22
+ // The note saying where the session came from is a user.message with source "system", the SDK's provenance
23
+ // for internal application context, so it is not taken for the person's request. Readable reasoning stays
24
+ // as assistant.reasoning events (timeline records; Copilot replays only its own opaque reasoning). The
25
+ // session starts from the last compaction summary: Copilot's compaction events point at checkpoint files
26
+ // and model-call metadata that cannot be made up for a summary written elsewhere.
27
+
28
+ /** Copilot CLI versions whose session files this writer was checked against. */
29
+ export const TESTED_VERSIONS = ["1.0.107"]
30
+
31
+ /** The installed version ("GitHub Copilot CLI 1.0.107." → "1.0.107"), or undefined when it is missing. */
32
+ export function copilotVersion(bin = "copilot"): string | undefined {
33
+ return versionOf(bin)?.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)?.[1]
34
+ }
35
+
36
+ /** The marker on the note's user.message; readers skip it like any internal context. */
37
+ export const NOTE_SOURCE = "system"
38
+
39
+ const continuedNote = (label: string, id: string, when: string) =>
40
+ `[This session continues ${label} session ${id}, carried over by brain session on ${when}. The turns below are its history; reasoning and anything listed as lost did not carry over. Check files before relying on details, since they may have changed.]`
41
+
42
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
43
+
44
+ /** Copilot records the resolved folder (/private/tmp, not /tmp). */
45
+ function realCwd(cwd: string): string {
46
+ try {
47
+ return realpathSync(cwd)
48
+ } catch {
49
+ return cwd
50
+ }
51
+ }
52
+
53
+ /** A YAML scalar Copilot's parser reads back as the same string. */
54
+ const yamlString = (s: string) => JSON.stringify(s)
55
+
56
+ type Call = Extract<Turn, { kind: "tool_call" }>
57
+ type Result = Extract<Turn, { kind: "tool_result" }>
58
+
59
+ // Argument names other tools use for the same thing.
60
+ const FILE = ["file_path", "path", "filePath", "absolute_path", "target_file", "file", "filename"]
61
+ const OLD = ["old_string", "oldString", "old_str", "old_text"]
62
+ const NEW = ["new_string", "newString", "new_str", "new_text"]
63
+ const CONTENT = ["content", "contents", "file_text", "text"]
64
+ const COMMAND = ["command", "cmd", "commandLine"]
65
+ const PATTERN = ["pattern", "regex", "query", "glob_pattern"]
66
+ const DIR = ["path", "dir_path", "directory", "target_directory"]
67
+
68
+ /**
69
+ * The call as Copilot CLI 1.0.107's own tool when its arguments fit one: view {path}, edit {path, old_str,
70
+ * new_str}, create {path, file_text}, bash {command}, grep / glob {pattern, paths}. Otherwise undefined, and
71
+ * the call keeps its own name and arguments.
72
+ */
73
+ export function copilotTool(c: Call): { name: string; args: Record<string, unknown> } | undefined {
74
+ const input = c.input && typeof c.input === "object" && !Array.isArray(c.input) ? (c.input as Record<string, unknown>) : {}
75
+ const pick = (keys: string[]) => keys.map((k) => input[k]).find((v) => typeof v === "string" || (Array.isArray(v) && v.every((x) => typeof x === "string"))) as string | string[] | undefined
76
+ const str = (keys: string[]) => {
77
+ const v = pick(keys)
78
+ return Array.isArray(v) ? v.join(" ") : v
79
+ }
80
+ const path = str(FILE)
81
+ switch (c.toolKind) {
82
+ case "read":
83
+ return path ? { name: "view", args: { path } } : undefined
84
+ case "edit": {
85
+ const [o, n] = [str(OLD), str(NEW)]
86
+ return path && o !== undefined && n !== undefined ? { name: "edit", args: { path, old_str: o, new_str: n } } : undefined
87
+ }
88
+ case "write": {
89
+ const text = str(CONTENT)
90
+ return path && text !== undefined ? { name: "create", args: { path, file_text: text } } : undefined
91
+ }
92
+ case "shell": {
93
+ const command = str(COMMAND)
94
+ return command ? { name: "bash", args: { command } } : undefined
95
+ }
96
+ case "search": {
97
+ const pattern = str(PATTERN)
98
+ const dir = str(DIR)
99
+ if (!pattern) return undefined
100
+ return { name: /glob/i.test(c.name) ? "glob" : "grep", args: { pattern, ...(dir ? { paths: dir } : {}) } }
101
+ }
102
+ default:
103
+ return undefined
104
+ }
105
+ }
106
+ type Event = { type: string; data: Record<string, unknown>; id: string; timestamp: string; parentId: string | null }
107
+
108
+ export interface EventsPlan {
109
+ events: Event[]
110
+ workspace: string
111
+ lost: string[]
112
+ /** What a read-back must find. */
113
+ expect: { users: number; assistants: number; calls: number }
114
+ title: string
115
+ }
116
+
117
+ /** The events and workspace.yaml of a new Copilot CLI session `sessionId` continuing `session`. Pure. */
118
+ export function planEvents(session: Session, opts: { sessionId: string; cwd: string; version: string; now: Date; branch?: string; commit?: string }): EventsPlan {
119
+ const drops = new Map<string, number>()
120
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
121
+ const label = toolLabel(session.header.tool)
122
+
123
+ // Copilot has no way to replay a foreign compaction, so the session starts from the last summary.
124
+ let turns = session.turns
125
+ let lastCompaction = -1
126
+ turns.forEach((t, i) => {
127
+ if (t.kind === "compaction") lastCompaction = i
128
+ })
129
+ if (lastCompaction >= 0) {
130
+ const before = turns.slice(0, lastCompaction).filter((t) => t.kind !== "compaction").length
131
+ if (before) drop("turns before the last compaction (the summary stands in for them)", before)
132
+ turns = turns.slice(lastCompaction)
133
+ }
134
+
135
+ const events: Event[] = []
136
+ let clock = opts.now.getTime()
137
+ let parent: string | null = null
138
+ const push = (type: string, data: Record<string, unknown>) => {
139
+ const id = randomUUID()
140
+ events.push({ type, data, id, timestamp: new Date(clock++).toISOString(), parentId: parent })
141
+ parent = id
142
+ }
143
+ const startTime = opts.now.toISOString()
144
+ push("session.start", {
145
+ sessionId: opts.sessionId,
146
+ version: TESTED_EVENT_VERSION,
147
+ producer: "copilot-agent",
148
+ copilotVersion: opts.version,
149
+ startTime,
150
+ contextTier: null,
151
+ context: { cwd: opts.cwd, ...(opts.branch ? { branch: opts.branch } : {}), ...(opts.commit ? { headCommit: opts.commit } : {}) },
152
+ alreadyInUse: false,
153
+ })
154
+ push("user.message", { content: continuedNote(label, session.header.id, startTime.slice(0, 10)), source: NOTE_SOURCE })
155
+
156
+ const results = new Map<string, Result>()
157
+ const callIds = new Set<string>()
158
+ for (const t of turns) {
159
+ if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
160
+ if (t.kind === "tool_call") callIds.add(t.id)
161
+ }
162
+ // Call ids are Copilot's own uuids, mapped once so the call and its result agree.
163
+ const ids = new Map<string, string>()
164
+ const idFor = (id: string) => {
165
+ let v = ids.get(id)
166
+ if (!v) ids.set(id, (v = randomUUID()))
167
+ return v
168
+ }
169
+ const toolName = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool"
170
+ const args = (input: unknown) => (input && typeof input === "object" && !Array.isArray(input) ? input : { input: input ?? null })
171
+ const nameOf = (c: Call) => copilotTool(c)?.name ?? toolName(c.name)
172
+ const argsOf = (c: Call) => copilotTool(c)?.args ?? args(c.input)
173
+
174
+ const expect = { users: 0, assistants: 0, calls: 0 }
175
+ // The assistant message being built: its text and calls, written once a result or a new message ends it.
176
+ let cur: { content: string; calls: Call[]; model?: string } | undefined
177
+ let reasoning: string[] = []
178
+ const answered = new Set<string>()
179
+ const flush = () => {
180
+ if (!cur) return
181
+ for (const text of reasoning) push("assistant.reasoning", { reasoningId: randomUUID(), content: text })
182
+ reasoning = []
183
+ // Copilot CLI 1.0.70 marks each request `type: "function"` and stamps the answering model on the message.
184
+ const toolRequests = cur.calls.map((c) => ({ name: nameOf(c), toolCallId: idFor(c.id), arguments: argsOf(c), type: "function" }))
185
+ push("assistant.message", { messageId: randomUUID(), ...(cur.model ? { model: cur.model } : {}), content: cur.content, ...(toolRequests.length ? { toolRequests } : {}) })
186
+ if (cur.content.trim()) expect.assistants++
187
+ for (const c of cur.calls) push("tool.execution_start", { toolCallId: idFor(c.id), toolName: nameOf(c), arguments: argsOf(c) })
188
+ // A call the session never answered is closed as a failure, so every request has its completion.
189
+ for (const c of cur.calls) {
190
+ if (results.has(c.id)) continue
191
+ push("tool.execution_complete", { toolCallId: idFor(c.id), success: false, error: { message: "No result was recorded for this call; the session moved on before it finished." } })
192
+ answered.add(c.id)
193
+ drop("tool calls without a result (closed as failures)")
194
+ }
195
+ cur = undefined
196
+ }
197
+
198
+ for (const t of turns) {
199
+ switch (t.kind) {
200
+ case "user": {
201
+ flush()
202
+ if (reasoning.length) drop("reasoning without a reply", reasoning.length)
203
+ reasoning = []
204
+ const attachments = (t.attachments ?? [])
205
+ .filter((a) => a.path)
206
+ .map((a) => ({ type: "file", path: a.path!, displayName: a.name ?? basename(a.path!), ...(a.mime ? { mimeType: a.mime } : {}) }))
207
+ if (!t.text.trim() && !attachments.length) break
208
+ push("user.message", { content: t.text, ...(attachments.length ? { attachments } : {}) })
209
+ expect.users++
210
+ break
211
+ }
212
+ case "assistant":
213
+ // Text after a tool call was written once the call's result came back: a new message.
214
+ if (cur?.calls.length) flush()
215
+ if (cur?.content.trim() && t.text.trim()) flush()
216
+ cur ??= { content: "", calls: [] }
217
+ cur.content = t.text
218
+ if (t.model) cur.model = t.model
219
+ break
220
+ case "tool_call":
221
+ if (ids.has(t.id)) {
222
+ drop("repeated tool call ids")
223
+ break
224
+ }
225
+ idFor(t.id)
226
+ cur ??= { content: "", calls: [] }
227
+ cur.calls.push(t)
228
+ expect.calls++
229
+ break
230
+ case "tool_result": {
231
+ if (!callIds.has(t.callId) || !ids.has(t.callId)) {
232
+ drop("tool results without a call")
233
+ break
234
+ }
235
+ if (answered.has(t.callId) || results.get(t.callId) !== t) {
236
+ drop("repeated tool results")
237
+ break
238
+ }
239
+ if (cur?.calls.some((c) => c.id === t.callId)) flush()
240
+ let output = t.output
241
+ if (t.fullPath) output += `\n\n[Full output: ${t.fullPath}]`
242
+ push(
243
+ "tool.execution_complete",
244
+ t.ok ? { toolCallId: idFor(t.callId), success: true, result: { content: output } } : { toolCallId: idFor(t.callId), success: false, error: { message: output || "The tool failed." } },
245
+ )
246
+ answered.add(t.callId)
247
+ break
248
+ }
249
+ case "reasoning":
250
+ if (cur?.calls.length) flush()
251
+ reasoning.push(t.text)
252
+ break
253
+ case "compaction":
254
+ flush()
255
+ push("user.message", { content: `[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${t.summary}`, source: NOTE_SOURCE })
256
+ break
257
+ case "file_edit":
258
+ if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
259
+ break
260
+ case "subagent":
261
+ drop("subagent transcripts (their results stay in the parent's tool results)")
262
+ break
263
+ }
264
+ }
265
+ flush()
266
+ if (reasoning.length) drop("reasoning without a reply", reasoning.length)
267
+
268
+ const title = `${session.header.title ?? firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60) ?? "Session"} (from ${label})`
269
+ const workspace = [
270
+ `id: ${opts.sessionId}`,
271
+ `cwd: ${yamlString(opts.cwd)}`,
272
+ "client_name: github/cli",
273
+ "last_client_name: github/cli",
274
+ `name: ${yamlString(title)}`,
275
+ "user_named: true",
276
+ "summary_count: 0",
277
+ "fork_count: 0",
278
+ `created_at: ${startTime}`,
279
+ `updated_at: ${new Date(clock).toISOString()}`,
280
+ "",
281
+ ].join("\n")
282
+ return { events, workspace, lost: [...drops].map(([what, n]) => `${what} (${n})`), expect, title }
283
+ }
284
+
285
+ const CHECKPOINT_INDEX = `# Checkpoint History
286
+
287
+ Checkpoints are listed in chronological order. Checkpoint 1 is the oldest, higher numbers are more recent.
288
+
289
+ | # | Title | File |
290
+ |---|-------|------|
291
+ `
292
+
293
+ /** The Copilot CLI writer; `version` is for tests, which must not depend on an installed binary. */
294
+ export function copilotCliWriter(opts: { version?: string } = {}): Writer {
295
+ const installed = () => opts.version ?? copilotVersion()
296
+ return {
297
+ tool: TOOL,
298
+ label: LABEL,
299
+ way: "native",
300
+
301
+ async ready(w: WriteOptions): Promise<Readiness> {
302
+ const v = installed()
303
+ if (!v) return { ok: false, why: "the Copilot CLI is not installed (`copilot --version` did not run)" }
304
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Copilot CLI ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
305
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Copilot resumes a session in its working directory` }
306
+ return { ok: true }
307
+ },
308
+
309
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
310
+ const cwd = realCwd(w.cwd)
311
+ const git = gitInfo(cwd, w.home)
312
+ const root = join(w.home, ".copilot", "session-state")
313
+ let sessionId = randomUUID()
314
+ while (exists(join(root, sessionId)) || exists(join(root, `${sessionId}.jsonl`))) sessionId = randomUUID()
315
+ const dir = join(root, sessionId)
316
+ const plan = planEvents(session, { sessionId, cwd, version: installed() ?? TESTED_VERSIONS[TESTED_VERSIONS.length - 1]!, now: new Date(), branch: git.gitBranch, commit: git.gitCommit })
317
+ const paths = { events: join(dir, "events.jsonl"), workspace: join(dir, "workspace.yaml"), index: join(dir, "checkpoints", "index.md") }
318
+ if (!w.dryRun) {
319
+ // mkdir without recursive on the session folder itself: an existing session is never touched.
320
+ mkdirSync(root, { recursive: true })
321
+ mkdirSync(dir)
322
+ try {
323
+ mkdirSync(join(dir, "checkpoints"))
324
+ writeFileSync(paths.events, `${plan.events.map((e) => JSON.stringify(e)).join("\n")}\n`, { flag: "wx" })
325
+ writeFileSync(paths.workspace, plan.workspace, { flag: "wx" })
326
+ writeFileSync(paths.index, CHECKPOINT_INDEX, { flag: "wx" })
327
+ const back = await reader.read({ tool: TOOL, sourceId: sessionId, path: paths.events, extra: { dir } }, w.home)
328
+ const n = countTurns(back)
329
+ const pairs = unpaired(back)
330
+ if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
331
+ throw new Error(
332
+ `the new Copilot CLI session did not read back as written (users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
333
+ )
334
+ readFileSync(paths.workspace, "utf8")
335
+ } catch (e) {
336
+ rmSync(dir, { recursive: true, force: true })
337
+ throw e
338
+ }
339
+ }
340
+ return {
341
+ tool: TOOL,
342
+ way: "native",
343
+ sessionId,
344
+ written: [paths.events, paths.workspace, paths.index],
345
+ open: `cd ${shellQuote(cwd)} && copilot --resume ${sessionId}`,
346
+ lost: plan.lost,
347
+ dryRun: !!w.dryRun,
348
+ }
349
+ },
350
+ }
351
+ }
352
+
353
+ export const copilotCliNativeWriter = copilotCliWriter()
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { crushReader } from "./reader.ts"
3
+ import { crushWriter } from "./writer.ts"
4
+
5
+ // Charm's Crush: reads every project's .crush/crush.db listed in projects.json, and continues any session as
6
+ // a new native session in the project's store (with Crush closed, after a backup).
7
+ export const readers: Reader[] = [crushReader]
8
+ export const writers: Writer[] = [crushWriter]
@@ -0,0 +1,180 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { join } from "node:path"
3
+ import { exists, gitInfo, readJson } from "../../fsutil.ts"
4
+ import { withSnapshot } from "../../sqlite.ts"
5
+ import { keepImage } from "../../session/attachments.ts"
6
+ import type { Attachment, Session, Turn } from "../../session/types.ts"
7
+ import { clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
8
+ import { CONTINUED_NOTE } from "../opencode/note.ts"
9
+ import type { Reader, SessionRef } from "../types.ts"
10
+ import { LABEL, TOOL, projectsFile } from "./records.ts"
11
+
12
+ // Reads Crush sessions from every project store listed in projects.json (records.ts). Each store is read from
13
+ // a copy, as Crush keeps it in WAL mode. Subagent sessions are the store's sessions whose parent is this one
14
+ // (their id is the agent tool call's id); the "title-<id>" sessions Crush uses to name a session are skipped.
15
+
16
+ /** The stores Crush has registered: one per project folder it ran in. */
17
+ function stores(home: string): Array<{ cwd: string; db: string }> {
18
+ const list = readJson<{ projects?: Array<{ path?: string; data_dir?: string }> }>(projectsFile(home))?.projects ?? []
19
+ const seen = new Set<string>()
20
+ const out: Array<{ cwd: string; db: string }> = []
21
+ for (const p of list) {
22
+ if (typeof p.path !== "string" || typeof p.data_dir !== "string") continue
23
+ const db = join(p.data_dir, "crush.db")
24
+ if (seen.has(db) || !exists(db)) continue
25
+ seen.add(db)
26
+ out.push({ cwd: p.path, db })
27
+ }
28
+ return out
29
+ }
30
+
31
+ export const crushReader: Reader = {
32
+ tool: TOOL,
33
+ label: LABEL,
34
+ async list(home) {
35
+ const refs: SessionRef[] = []
36
+ for (const { cwd, db } of stores(home)) {
37
+ try {
38
+ const rows = withSnapshot(db, (d) => d.query("SELECT id, title, updated_at FROM sessions WHERE parent_session_id IS NULL").all()) as Array<Record<string, any>>
39
+ for (const r of rows)
40
+ refs.push({
41
+ tool: TOOL,
42
+ sourceId: String(r.id),
43
+ path: db,
44
+ ...(r.title ? { title: String(r.title) } : {}),
45
+ cwd,
46
+ ...(iso(r.updated_at) ? { updatedAt: iso(r.updated_at)! } : {}),
47
+ })
48
+ } catch {
49
+ // A store that cannot be copied or opened lists nothing; read() says why for a session in it.
50
+ }
51
+ }
52
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
53
+ },
54
+ async read(ref) {
55
+ if (!exists(ref.path)) throw new Error(`Crush store not found: ${ref.path}`)
56
+ return withSnapshot(ref.path, (d) => readCrush(d, ref.sourceId, { path: ref.path, ...(ref.cwd ? { cwd: ref.cwd } : {}) }))
57
+ },
58
+ }
59
+
60
+ const json = (s: unknown): any => {
61
+ try {
62
+ return JSON.parse(String(s))
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ /** One session from an open Crush store, with its subagent sessions two levels down. */
69
+ export function readCrush(d: Database, id: string, where: { path: string; cwd?: string }, depth = 0): Session {
70
+ const row = d.query("SELECT * FROM sessions WHERE id = ?").get(id) as Record<string, any> | null
71
+ if (!row) throw new Error(`session ${id} not found`)
72
+ const cwd = where.cwd
73
+ const h = header(TOOL, id, {
74
+ sourcePath: where.path,
75
+ ...(row.title ? { title: String(row.title) } : {}),
76
+ project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
77
+ ...(iso(row.created_at) ? { startedAt: iso(row.created_at)! } : {}),
78
+ ...(iso(row.updated_at) ? { updatedAt: iso(row.updated_at)! } : {}),
79
+ ...(row.parent_session_id ? { parent: { id: `${TOOL}:${row.parent_session_id}`, kind: "subagent" as const } } : {}),
80
+ })
81
+ const lost = lossCounter()
82
+ const turns: Turn[] = []
83
+ const callIds = new Set<string>()
84
+ const children = new Set(
85
+ (d.query("SELECT id FROM sessions WHERE parent_session_id = ? AND id NOT LIKE 'title-%'").all(id) as Array<{ id: string }>).map((r) => r.id),
86
+ )
87
+ // created_at has one-second resolution; Crush writes a session's messages in order, so rowid breaks ties.
88
+ for (const m of d.query("SELECT rowid, * FROM messages WHERE session_id = ? ORDER BY created_at, rowid").all(id) as Array<Record<string, any>>) {
89
+ const parts = json(m.parts)
90
+ if (!Array.isArray(parts)) {
91
+ lost.add("unreadable messages")
92
+ continue
93
+ }
94
+ const at = iso(m.created_at)
95
+ const of = (type: string) => parts.filter((p: any) => p?.type === type).map((p: any) => (p.data ?? {}) as Record<string, any>)
96
+ if (m.role === "user") {
97
+ const texts = of("text")
98
+ if (texts.some((t) => t.hidden)) {
99
+ // A continuation Crush (or brain) added for the model only; brain's says where the session came from.
100
+ const continued = String(texts[0]?.text ?? "").match(CONTINUED_NOTE)?.[1]
101
+ if (continued) h.parent ??= { id: continued, kind: "continued" }
102
+ else lost.add("messages the tool injected as the user")
103
+ continue
104
+ }
105
+ for (const sc of of("shell_command")) {
106
+ const callId = `shell-${m.id}-${callIds.size}`
107
+ callIds.add(callId)
108
+ turns.push({ kind: "tool_call", ...(at ? { at } : {}), id: callId, name: "bash", toolKind: "shell", input: { command: sc.command } })
109
+ turns.push({ kind: "tool_result", callId, ok: !sc.exit_code, output: clip(String(sc.output ?? "")).text })
110
+ }
111
+ if (of("sub_agent_report").length) lost.add("subagent reports", of("sub_agent_report").length)
112
+ const attachments: Attachment[] = [
113
+ ...of("binary").map((b) => {
114
+ const mime = String(b.MIMEType ?? "")
115
+ const kept = mime.startsWith("image/") && typeof b.Data === "string" ? keepImage(b.Data, mime) : undefined
116
+ if (kept) return kept
117
+ return { kind: (mime.startsWith("image/") ? "image" : "file") as Attachment["kind"], ...(b.Path ? { path: String(b.Path) } : {}), ...(b.MIMEType ? { mime } : {}) }
118
+ }),
119
+ ...of("image_url").map(() => ({ kind: "image" as const })),
120
+ ]
121
+ if (of("image_url").length) lost.add("attachment data", of("image_url").length)
122
+ const text = texts.map((t) => String(t.text ?? "")).join("\n\n")
123
+ if (text.trim() || attachments.length) turns.push({ kind: "user", ...(at ? { at } : {}), text, ...(attachments.length ? { attachments } : {}) })
124
+ continue
125
+ }
126
+ if (m.role === "tool") {
127
+ for (const r of of("tool_result")) {
128
+ const callId = String(r.tool_call_id ?? "")
129
+ if (r.data) lost.add("images in tool results")
130
+ const out = clip(String(r.content ?? ""))
131
+ turns.push({ kind: "tool_result", ...(at ? { at } : {}), callId, ok: !r.is_error, output: out.text, ...(out.truncated ? { truncated: true } : {}) })
132
+ }
133
+ continue
134
+ }
135
+ if (m.role !== "assistant") {
136
+ lost.add(`unknown message roles: ${String(m.role)}`)
137
+ continue
138
+ }
139
+ const text = of("text").map((t) => String(t.text ?? "")).join("\n\n")
140
+ if (m.is_summary_message) {
141
+ if (text.trim()) turns.push({ kind: "compaction", ...(at ? { at } : {}), summary: text })
142
+ continue
143
+ }
144
+ const model = m.model ? String(m.model) : undefined
145
+ if (model && (text.trim() || of("tool_call").length)) noteModel(h, model)
146
+ for (const r of of("reasoning")) {
147
+ const signed = !!(r.signature || r.thought_signature || r.responses_data)
148
+ if (typeof r.thinking === "string" && r.thinking.trim()) {
149
+ turns.push({ kind: "reasoning", ...(at ? { at } : {}), text: r.thinking })
150
+ if (signed) lost.add("reasoning signatures")
151
+ } else if (signed) lost.add("signed reasoning")
152
+ }
153
+ if (text.trim()) turns.push({ kind: "assistant", ...(at ? { at } : {}), text, ...(model ? { model } : {}) })
154
+ for (const c of of("tool_call")) {
155
+ const name = String(c.name ?? "unknown")
156
+ let callId = String(c.id ?? "")
157
+ for (let n = 2; !callId || callIds.has(callId); n++) callId = `${String(c.id ?? "call")}#${n}`
158
+ callIds.add(callId)
159
+ const input = json(c.input) ?? c.input ?? {}
160
+ turns.push({ kind: "tool_call", ...(at ? { at } : {}), id: callId, name, toolKind: toolKindOf(name), input })
161
+ if (children.has(String(c.id))) turns.push({ kind: "subagent", ...(at ? { at } : {}), sessionId: `${TOOL}:${c.id}`, callId })
162
+ }
163
+ const finish = of("finish")[0]
164
+ if (finish && (finish.reason === "error" || finish.reason === "content_filter")) lost.add("error notices on replies")
165
+ }
166
+ lost.flush(h)
167
+ const s: Session = { header: h, turns }
168
+ if (depth < 2 && children.size) {
169
+ const kids: Session[] = []
170
+ for (const child of children) {
171
+ try {
172
+ kids.push(readCrush(d, child, where, depth + 1))
173
+ } catch {
174
+ h.lost.push(`unreadable subagent session (${child})`)
175
+ }
176
+ }
177
+ if (kids.length) s.children = kids
178
+ }
179
+ return s
180
+ }