@100xprompt/brain 0.0.0-stage → 0.1.1

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 (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -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 +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,379 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import { toolLabel } from "../../brief.ts"
4
+ import { exists, isDir, versionOf } from "../../fsutil.ts"
5
+ import type { Session, Turn, Usage } from "../../session/types.ts"
6
+ import { imageDataUrl } from "../../session/attachments.ts"
7
+ import { firstUserText } from "../../session/util.ts"
8
+ import type { Readiness, WriteResult, Writer } from "../types.ts"
9
+ import type { Bundle } from "./reader.ts"
10
+ import { idMinter, mintId, random62 } from "./ids.ts"
11
+ import { continuedNote } from "./note.ts"
12
+ import { HUNDREDX, OPENCODE_V1, mapCall, type Dialect } from "./tools.ts"
13
+
14
+ // Continuing a session in OpenCode 1.x, 100xprompt or Kilo through their own importer: the session is written
15
+ // as the JSON `export` produces, {info, messages: [{info, parts}]}, and `<tool> import <file>` stores it as a
16
+ // new session under the project of the folder it runs in. 100xprompt's importer writes the records as they
17
+ // are, unchecked; OpenCode 1.18's and Kilo's decode each against their schema and replace the projectID and
18
+ // directory with the folder's. So every record is complete and valid for all three.
19
+ //
20
+ // Verified live (2026-11-08, temp homes, no network): the real `import`, then `session list` and `export`,
21
+ // on 100xprompt 0.1.136, OpenCode 1.18.25 and Kilo 7.8.27. Shapes checked against 100xprompt's source
22
+ // (src/6-harness/session/message-v2.ts, src/9-surface/cli/cmd/{export,import}.ts), OpenCode v1.18.25 and
23
+ // Kilo's packages/opencode/src/cli/cmd/import.ts with packages/schema/src/v1/session.ts.
24
+ // OpenCode 2.x imports a different format (v2.ts).
25
+
26
+ export interface Target {
27
+ tool: string
28
+ label: string
29
+ /** The binary's name, and its install folder under home (~/.100xprompt/bin, ~/.opencode/bin, ~/.kilo/bin). */
30
+ bin: string
31
+ installDir: string
32
+ /** Versions whose import format was checked. */
33
+ tested: string[]
34
+ /** OpenCode's and Kilo's session schema require a slug; 100xprompt's has none. */
35
+ slug: boolean
36
+ dialect: Dialect
37
+ }
38
+
39
+ export const HUNDREDX_TARGET: Target = { tool: "100xprompt", label: "100xprompt", bin: "100xprompt", installDir: ".100xprompt", tested: ["0.1.136"], slug: false, dialect: HUNDREDX }
40
+ export const OPENCODE_TARGET: Target = { tool: "opencode", label: "OpenCode", bin: "opencode", installDir: ".opencode", tested: ["1.18.25"], slug: true, dialect: OPENCODE_V1 }
41
+
42
+ /** The binary to run: the installer's copy under home first, else whatever is on PATH. */
43
+ export function resolveBin(t: Pick<Target, "bin" | "installDir">, home: string): { path: string; command: string } {
44
+ const own = join(home, t.installDir, "bin", t.bin)
45
+ if (exists(own)) return { path: own, command: Bun.which(t.bin) === own ? t.bin : own }
46
+ return { path: t.bin, command: t.bin }
47
+ }
48
+
49
+ export const versionNumber = (v: string | undefined) => v?.match(/\d+\.\d+\.\d+/)?.[0]
50
+
51
+ export function importWriter(t: Target): Writer {
52
+ return {
53
+ tool: t.tool,
54
+ label: t.label,
55
+ way: "import",
56
+ async ready(opts): Promise<Readiness> {
57
+ const bin = resolveBin(t, opts.home)
58
+ const raw = versionOf(bin.path)
59
+ const v = versionNumber(raw)
60
+ if (!raw) return { ok: false, why: `${t.label} is not installed (no ${t.bin} on PATH or in ~/${t.installDir}/bin)` }
61
+ if (!v || !t.tested.includes(v)) return { ok: false, why: `${t.label} ${v ?? raw} has not been tested; the import format was checked on ${t.tested.join(", ")}` }
62
+ if (!isDir(opts.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${opts.cwd}` }
63
+ return { ok: true }
64
+ },
65
+ async write(session, opts): Promise<WriteResult> {
66
+ const bin = resolveBin(t, opts.home)
67
+ const version = versionNumber(versionOf(bin.path)) ?? t.tested.at(-1)!
68
+ const { bundle, sessionId, lost } = buildExport(session, {
69
+ label: t.label,
70
+ dialect: t.dialect,
71
+ cwd: opts.cwd,
72
+ version,
73
+ projectID: projectIdOf(opts.cwd, t.bin),
74
+ slug: t.slug,
75
+ })
76
+ const file = join(opts.home, ".brain", "exports", `${t.tool}-${sessionId}.json`)
77
+ if (!opts.dryRun) {
78
+ mkdirSync(dirname(file), { recursive: true })
79
+ writeFileSync(file, `${JSON.stringify(bundle, null, 2)}\n`)
80
+ }
81
+ return {
82
+ tool: t.tool,
83
+ way: "import",
84
+ sessionId,
85
+ written: [file],
86
+ // The importer files the session under the project of the folder it runs in, so it runs there.
87
+ open: `cd ${sh(opts.cwd)} && ${sh(bin.command)} import ${sh(file)} && ${sh(bin.command)} --session ${sessionId}`,
88
+ lost,
89
+ dryRun: !!opts.dryRun,
90
+ }
91
+ },
92
+ }
93
+ }
94
+
95
+ export const sh = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
96
+
97
+ /**
98
+ * The project id 100xprompt and OpenCode 1.x give a folder: the repository's first root commit, cached by the
99
+ * tool in .git/<tool>, or "global" outside a repository. 100xprompt's importer files the session under the id
100
+ * it computes itself and keeps the record's own; this keeps them in agreement.
101
+ */
102
+ export function projectIdOf(cwd: string, tool: string): string {
103
+ let dir = cwd
104
+ for (let i = 0; i < 30 && dir && dir !== "/"; i++) {
105
+ const git = join(dir, ".git")
106
+ if (exists(git)) {
107
+ // A worktree's .git is a file naming the real git folder, relative or absolute.
108
+ const gitDir = isDir(git) ? git : resolve(dir, readText(git).replace(/^gitdir:\s*/, "").trim())
109
+ const cached = readText(join(gitDir, tool)).trim()
110
+ if (cached) return cached
111
+ const p = Bun.spawnSync(["git", "rev-list", "--max-parents=0", "--all"], { cwd: dir, stdout: "pipe", stderr: "pipe", timeout: 10_000 })
112
+ const roots = p.exitCode === 0 ? p.stdout.toString().split("\n").map((x) => x.trim()).filter(Boolean).sort() : []
113
+ return roots[0] ?? "global"
114
+ }
115
+ dir = dirname(dir)
116
+ }
117
+ return "global"
118
+ }
119
+
120
+ function readText(p: string): string {
121
+ try {
122
+ return readFileSync(p, "utf8")
123
+ } catch {
124
+ return ""
125
+ }
126
+ }
127
+
128
+ /** The provider a model name belongs to; "provider/model" names say so themselves. */
129
+ export function modelRef(model: string | undefined): { providerID: string; modelID: string } {
130
+ if (!model) return { providerID: "unknown", modelID: "unknown" }
131
+ const slash = model.indexOf("/")
132
+ if (slash > 0) return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
133
+ const providerID = /claude|opus|sonnet|haiku/i.test(model)
134
+ ? "anthropic"
135
+ : /gemini|gemma/i.test(model)
136
+ ? "google"
137
+ : /^(gpt|o\d|codex|chatgpt)/i.test(model)
138
+ ? "openai"
139
+ : /grok/i.test(model)
140
+ ? "xai"
141
+ : /deepseek/i.test(model)
142
+ ? "deepseek"
143
+ : "unknown"
144
+ return { providerID, modelID: model }
145
+ }
146
+
147
+ export const zeroTokens = () => ({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } })
148
+
149
+ export function addUsage(t: ReturnType<typeof zeroTokens>, u: Usage | undefined) {
150
+ if (!u) return
151
+ t.input += u.input ?? 0
152
+ t.output += u.output ?? 0
153
+ t.cache.read += u.cacheRead ?? 0
154
+ t.cache.write += u.cacheWrite ?? 0
155
+ }
156
+
157
+ /** The short label the tool shows beside a call. */
158
+ export function callTitle(name: string, input: Record<string, unknown>): string {
159
+ const pick = input.description ?? input.filePath ?? input.file_path ?? input.path ?? input.pattern ?? input.url ?? input.command ?? input.query
160
+ const text = Array.isArray(pick) ? pick.join(" ") : typeof pick === "string" ? pick : name
161
+ return text.length > 120 ? `${text.slice(0, 119)}…` : text
162
+ }
163
+
164
+ /** The new session's title: the source's, marked with where it came from. */
165
+ export function continuedTitle(s: Session): string {
166
+ const title = (s.header.title ?? firstUserText(s) ?? "").replace(/\s+/g, " ").trim().slice(0, 100) || "Continued session"
167
+ return `${title} (from ${toolLabel(s.header.tool)})`
168
+ }
169
+
170
+ export interface ExportOptions {
171
+ /** The target's name, for what is lost. */
172
+ label: string
173
+ dialect: Dialect
174
+ cwd: string
175
+ version: string
176
+ projectID: string
177
+ slug: boolean
178
+ /** The clock for new ids and the session's update time (tests pin it). */
179
+ now?: number
180
+ }
181
+
182
+ /**
183
+ * The portable session as an export bundle: the continuation note as the tool's own hidden (synthetic) user
184
+ * message, one user message per user turn, one assistant message per model step (the text, reasoning and tool
185
+ * calls up to the results they wait on, as the tools store them), each tool call merged with its result into
186
+ * one tool part under the target's own tool name, and a compaction as the tool's own compaction pair.
187
+ */
188
+ export function buildExport(s: Session, o: ExportOptions): { bundle: Bundle; sessionId: string; lost: string[] } {
189
+ const now = o.now ?? Date.now()
190
+ // New ids from now, so the imported messages sort in order and after anything already stored.
191
+ const next = idMinter(now)
192
+ const sessionId = mintId("ses", now, 1, true)
193
+ const messages: Bundle["messages"] = []
194
+ const lost = new Map<string, number>()
195
+ const drop = (what: string, n = 1) => lost.set(what, (lost.get(what) ?? 0) + n)
196
+ const mapped = { n: 0 }
197
+
198
+ // Times must be numbers and must not go backwards, or the stores read messages out of order.
199
+ let clock = Date.parse(s.header.startedAt ?? "") || now - s.turns.length * 1000
200
+ const tick = (at?: string) => {
201
+ const t = at ? Date.parse(at) : NaN
202
+ clock = Number.isFinite(t) && t > clock ? t : clock + 1
203
+ return clock
204
+ }
205
+ const firstClock = clock
206
+
207
+ const agent = o.dialect.agent ?? "build"
208
+ let model = modelRef(s.header.models.at(-1))
209
+ let parentID: string | undefined
210
+ let step: { info: Record<string, any>; parts: Array<Record<string, any>>; tools: boolean; results: boolean; tokens: ReturnType<typeof zeroTokens> } | undefined
211
+ const calls = new Map<string, Record<string, any>>()
212
+ const answered = new Set<string>()
213
+
214
+ const user = (parts: (messageID: string, at: number) => Array<Record<string, any>>, at?: string) => {
215
+ closeStep()
216
+ const id = next("msg")
217
+ const created = tick(at)
218
+ messages.push({ info: { id, sessionID: sessionId, role: "user", time: { created }, agent, model, summary: { diffs: [] } }, parts: parts(id, created) })
219
+ parentID = id
220
+ return id
221
+ }
222
+ const part = (messageID: string, fields: Record<string, unknown>) => ({ id: next("prt"), sessionID: sessionId, messageID, ...fields })
223
+ const assistantInfo = (id: string, created: number, extra: Record<string, unknown> = {}) => ({
224
+ id,
225
+ sessionID: sessionId,
226
+ role: "assistant",
227
+ time: { created, completed: created },
228
+ parentID,
229
+ modelID: model.modelID,
230
+ providerID: model.providerID,
231
+ mode: agent,
232
+ agent,
233
+ path: { cwd: o.cwd, root: o.cwd },
234
+ ...extra,
235
+ cost: 0,
236
+ tokens: zeroTokens(),
237
+ finish: "stop",
238
+ })
239
+
240
+ // The note first: a synthetic part is sent to the model and hidden in the TUI, like the tools' own nudges.
241
+ const note = continuedNote(toolLabel(s.header.tool), s.header.id, new Date(now).toISOString().slice(0, 10))
242
+ user((id, created) => [part(id, { type: "text", text: note, synthetic: true, time: { start: created, end: created } })])
243
+
244
+ const openStep = (at?: string) => {
245
+ if (step && !step.results) return step
246
+ closeStep()
247
+ const id = next("msg")
248
+ step = { info: assistantInfo(id, tick(at)), parts: [], tools: false, results: false, tokens: zeroTokens() }
249
+ step.parts.push(part(id, { type: "step-start" }))
250
+ messages.push({ info: step.info, parts: step.parts })
251
+ return step
252
+ }
253
+ function closeStep() {
254
+ if (!step) return
255
+ const reason = step.tools ? "tool-calls" : "stop"
256
+ step.info.finish = reason
257
+ step.info.tokens = step.tokens
258
+ step.info.time.completed = clock
259
+ step.parts.push(part(step.info.id, { type: "step-finish", reason, cost: 0, tokens: step.tokens }))
260
+ step = undefined
261
+ }
262
+
263
+ for (const t of s.turns as Turn[]) {
264
+ switch (t.kind) {
265
+ case "user": {
266
+ // Kept images are embedded as file parts with a data: URL (what OpenCode stores for a pasted image); the rest
267
+ // travel by path, which another machine or a later day may not have: the text says they were there.
268
+ const pics: Array<{ url: string; mime: string; name: string }> = []
269
+ let n = 0
270
+ for (const a of t.attachments ?? []) {
271
+ const url = imageDataUrl(a)
272
+ if (url) pics.push({ url, mime: url.slice(5, url.indexOf(";")), name: a.name ?? a.path?.split("/").pop() ?? "image" })
273
+ else n++
274
+ }
275
+ if (n) drop("attachments", n)
276
+ const text = t.text.trim() ? t.text : n ? `(${n} attachment${n > 1 ? "s" : ""} not carried over)` : ""
277
+ if (!text && !pics.length) break // providers reject an empty user message
278
+ user((id, created) => [
279
+ ...(text ? [part(id, { type: "text", text, time: { start: created, end: created } })] : []),
280
+ ...pics.map((p) => part(id, { type: "file", mime: p.mime, filename: p.name, url: p.url })),
281
+ ], t.at)
282
+ break
283
+ }
284
+ case "assistant": {
285
+ if (!t.text.trim()) break
286
+ if (t.model) model = modelRef(t.model)
287
+ const st = openStep(t.at)
288
+ const at = tick(t.at)
289
+ st.parts.push(part(st.info.id, { type: "text", text: t.text, time: { start: at, end: at } }))
290
+ addUsage(st.tokens, t.usage)
291
+ break
292
+ }
293
+ case "reasoning": {
294
+ const st = openStep(t.at)
295
+ const at = tick(t.at)
296
+ st.parts.push(part(st.info.id, { type: "reasoning", text: t.text, time: { start: at, end: at } }))
297
+ // Readable reasoning has no provider signature: 100xprompt shows it but does not send it back, and
298
+ // OpenCode and Kilo send it to another model as plain text.
299
+ drop(o.dialect === HUNDREDX ? "reasoning: shown in the session but not sent back to the model" : "reasoning: kept as text, without provider signatures")
300
+ break
301
+ }
302
+ case "tool_call": {
303
+ const st = openStep(t.at)
304
+ const at = tick(t.at)
305
+ const call = mapCall(o.dialect, t.name, t.toolKind, t.input, mapped)
306
+ // Foreign call ids (toolu_…, call_…, Cursor's) are replaced by fresh ones every provider accepts.
307
+ const p = part(st.info.id, {
308
+ type: "tool",
309
+ callID: `call_${random62(24)}`,
310
+ tool: call.tool,
311
+ state: { status: "error", input: call.input, error: "No result was recorded for this call.", time: { start: at, end: at } },
312
+ })
313
+ st.parts.push(p)
314
+ st.tools = true
315
+ calls.set(t.id, p)
316
+ break
317
+ }
318
+ case "tool_result": {
319
+ const p = calls.get(t.callId)
320
+ if (!p) {
321
+ drop("tool results without a call")
322
+ break
323
+ }
324
+ answered.add(t.callId)
325
+ const start = p.state.time.start as number
326
+ const end = Math.max(start, tick(t.at))
327
+ p.state = t.ok
328
+ ? {
329
+ status: "completed",
330
+ input: p.state.input,
331
+ output: t.output,
332
+ title: callTitle(String(p.tool), p.state.input),
333
+ metadata: t.truncated ? { truncated: true, ...(t.fullPath ? { outputPath: t.fullPath } : {}) } : {},
334
+ time: { start, end },
335
+ }
336
+ : { status: "error", input: p.state.input, error: t.output || "The call failed.", time: { start, end } }
337
+ if (step) step.results = true
338
+ break
339
+ }
340
+ case "compaction": {
341
+ // The tool's own pair: a user message with a compaction part, then the summary as an assistant reply.
342
+ // The loaders send the model only what follows the last such pair.
343
+ user((id) => [part(id, { type: "compaction", auto: true })], t.at)
344
+ const id = next("msg")
345
+ const created = tick(t.at)
346
+ messages.push({
347
+ info: assistantInfo(id, created, { summary: true }),
348
+ parts: [part(id, { type: "text", text: t.summary, time: { start: created, end: created } })],
349
+ })
350
+ break
351
+ }
352
+ case "file_edit":
353
+ // Both stores record edits as snapshot hashes of their own shadow repository, which cannot be made up;
354
+ // an edit made by a call travels as that call.
355
+ if (!t.callId) drop("file edits not made by a tool call")
356
+ break
357
+ case "subagent":
358
+ drop("subagent sessions (their call and result are kept)")
359
+ break
360
+ }
361
+ }
362
+ closeStep()
363
+
364
+ const unanswered = calls.size - answered.size
365
+ if (unanswered > 0) drop("calls with no result, marked as failed", unanswered)
366
+ if (calls.size) drop("tool-call ids replaced", calls.size)
367
+ if (mapped.n) drop(`tool calls renamed to ${o.label}'s own tools`, mapped.n)
368
+
369
+ const info: Record<string, unknown> = {
370
+ id: sessionId,
371
+ ...(o.slug ? { slug: `brain-${sessionId.slice(-8).toLowerCase()}` } : {}),
372
+ projectID: o.projectID,
373
+ directory: o.cwd,
374
+ title: continuedTitle(s),
375
+ version: o.version,
376
+ time: { created: firstClock, updated: Math.max(now, clock) },
377
+ }
378
+ return { bundle: { info, messages }, sessionId, lost: [...lost].map(([what, n]) => `${what} (${n})`) }
379
+ }
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { openhandsReader } from "./reader.ts"
3
+ import { openhandsNativeWriter } from "./writer.ts"
4
+
5
+ // OpenHands CLI: reads its conversation event logs, and continues any session as a new conversation that
6
+ // `openhands --resume <id>` opens.
7
+ export const readers: Reader[] = [openhandsReader]
8
+ export const writers: Writer[] = [openhandsNativeWriter]
@@ -0,0 +1,222 @@
1
+ import { join } from "node:path"
2
+ import { isDir, ls, mtime, readJson } from "../../fsutil.ts"
3
+ import type { Attachment, Session, Turn } from "../../session/types.ts"
4
+ import { clip, header, lossCounter, noteModel } from "../../session/util.ts"
5
+ import type { Reader, SessionRef } from "../types.ts"
6
+ import { CONTINUED_NOTE, EVENT_FILE, LABEL, TOOL, conversationsDir, eventTime, openhandsKind } from "./records.ts"
7
+
8
+ // OpenHands CLI conversations: one folder per conversation, one JSON file per event (see records.ts).
9
+ // Events become turns in index order: user and agent MessageEvents are messages, an ActionEvent is the
10
+ // agent's thought plus one tool call (parallel calls share an llm_response_id), ObservationEvent /
11
+ // AgentErrorEvent / UserRejectObservation are its result, and a Condensation with a summary is a compaction.
12
+
13
+ type Ev = Record<string, any>
14
+
15
+ /** Event files in index order; a gap ends the log, as EventLog._scan_and_build_index stops there. */
16
+ export function eventFiles(dir: string): string[] {
17
+ const byIndex = new Map<number, string>()
18
+ for (const name of ls(join(dir, "events"))) {
19
+ const m = name.match(EVENT_FILE)
20
+ if (m) byIndex.set(Number(m[1]), name)
21
+ }
22
+ const out: string[] = []
23
+ for (let i = 0; byIndex.has(i); i++) out.push(join(dir, "events", byIndex.get(i)!))
24
+ return out
25
+ }
26
+
27
+ const texts = (content: unknown): string =>
28
+ (Array.isArray(content) ? content : [])
29
+ .filter((c: Ev) => c?.type === "text" && typeof c.text === "string")
30
+ .map((c: Ev) => c.text as string)
31
+ .join("")
32
+
33
+ const images = (content: unknown): number => (Array.isArray(content) ? content : []).filter((c: Ev) => c?.type === "image").reduce((n: number, c: Ev) => n + (Array.isArray(c.image_urls) ? c.image_urls.length : 1), 0)
34
+
35
+ /** The folder the CLI states in every conversation's system prompt (its agent context suffix). */
36
+ export const workingDir = (dynamic: string | undefined) => dynamic?.match(/^Your current working directory is: (.+)$/m)?.[1]?.trim()
37
+
38
+ /** Arguments of a tool call as an object when they are JSON, else as the raw string. */
39
+ function args(raw: unknown): unknown {
40
+ if (typeof raw !== "string") return raw ?? {}
41
+ try {
42
+ return JSON.parse(raw)
43
+ } catch {
44
+ return raw
45
+ }
46
+ }
47
+
48
+ /** The first user message's text, which the CLI shows as the title. */
49
+ function firstPrompt(files: string[]): string | undefined {
50
+ for (const f of files.slice(0, 50)) {
51
+ const e = readJson<Ev>(f)
52
+ if (e?.kind === "MessageEvent" && e.source === "user") {
53
+ const t = texts(e.llm_message?.content).trim()
54
+ if (t) return t
55
+ }
56
+ }
57
+ return undefined
58
+ }
59
+
60
+ export function readConversation(dir: string, id: string): Session {
61
+ const files = eventFiles(dir)
62
+ if (!files.length) throw new Error(`no events in the OpenHands conversation ${dir}`)
63
+ const base = readJson<Ev>(join(dir, "base_state.json"))
64
+ const h = header(TOOL, id, { sourcePath: dir, project: { cwd: base?.workspace?.working_dir } })
65
+ noteModel(h, base?.agent?.llm?.model)
66
+ const lost = lossCounter()
67
+ const turns: Turn[] = []
68
+ const calls = new Set<string>()
69
+ const answered = new Set<string>()
70
+ let first: string | undefined
71
+ let last: string | undefined
72
+
73
+ const reasoning = (e: Ev, msg?: Ev) => {
74
+ const text = (msg?.reasoning_content ?? e.reasoning_content) as unknown
75
+ if (typeof text === "string" && text.trim()) turns.push({ kind: "reasoning", text })
76
+ const blocks = (msg?.thinking_blocks ?? e.thinking_blocks) as unknown
77
+ if (Array.isArray(blocks) && blocks.length) lost.add("signed reasoning", blocks.length)
78
+ const item = (msg?.responses_reasoning_item ?? e.responses_reasoning_item) as Ev | undefined
79
+ if (item) {
80
+ const parts = [...(item.summary ?? []), ...(item.content ?? [])].filter((s: unknown) => typeof s === "string" && s.trim())
81
+ if (parts.length) turns.push({ kind: "reasoning", text: parts.join("\n") })
82
+ if (item.encrypted_content) lost.add("encrypted reasoning")
83
+ }
84
+ }
85
+ const result = (e: Ev, ok: boolean, output: string) => {
86
+ const callId = String(e.tool_call_id ?? "")
87
+ if (!calls.has(callId)) return lost.add("results without a call")
88
+ if (answered.has(callId)) return lost.add("repeated results")
89
+ answered.add(callId)
90
+ const c = clip(output)
91
+ turns.push({ kind: "tool_result", ...(at(e) ? { at: at(e) } : {}), callId, ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
92
+ }
93
+ const at = (e: Ev) => eventTime(e.timestamp)
94
+
95
+ for (const f of files) {
96
+ const e = readJson<Ev>(f)
97
+ if (!e) {
98
+ lost.add("unreadable events")
99
+ continue
100
+ }
101
+ const when = at(e)
102
+ if (when) {
103
+ first ??= when
104
+ last = when
105
+ }
106
+ switch (e.kind) {
107
+ case "SystemPromptEvent": {
108
+ const dynamic = texts([e.dynamic_context])
109
+ const note = dynamic.match(CONTINUED_NOTE)
110
+ if (note) h.parent = { id: note[1]!, kind: "continued" }
111
+ h.project.cwd ??= workingDir(dynamic)
112
+ break
113
+ }
114
+ case "MessageEvent": {
115
+ const msg = (e.llm_message ?? {}) as Ev
116
+ const text = texts(msg.content)
117
+ if (e.source === "user") {
118
+ const n = images(msg.content)
119
+ const attachments: Attachment[] = Array.from({ length: n }, () => ({ kind: "image" }))
120
+ if (n) lost.add("images (kept inline by OpenHands, not as files)", n)
121
+ if (text.trim() || n) turns.push({ kind: "user", ...(when ? { at: when } : {}), text, ...(n ? { attachments } : {}) })
122
+ } else {
123
+ reasoning(e, msg)
124
+ if (text.trim()) turns.push({ kind: "assistant", ...(when ? { at: when } : {}), text, ...(h.models.length ? { model: h.models[0] } : {}) })
125
+ }
126
+ break
127
+ }
128
+ case "ActionEvent": {
129
+ reasoning(e)
130
+ const thought = texts(e.thought)
131
+ if (thought.trim()) turns.push({ kind: "assistant", ...(when ? { at: when } : {}), text: thought, ...(h.models.length ? { model: h.models[0] } : {}) })
132
+ const call = (e.tool_call ?? {}) as Ev
133
+ const id = String(e.tool_call_id ?? call.id ?? "")
134
+ if (!id || calls.has(id)) {
135
+ lost.add("tool calls without a usable id")
136
+ break
137
+ }
138
+ calls.add(id)
139
+ const name = String(e.tool_name ?? call.name ?? "tool")
140
+ const input = args(call.arguments)
141
+ turns.push({ kind: "tool_call", ...(when ? { at: when } : {}), id, name, toolKind: openhandsKind(name, input), input })
142
+ const action = (e.action ?? {}) as Ev
143
+ if (name === "file_editor" && typeof action.path === "string" && ["create", "str_replace", "insert"].includes(action.command))
144
+ turns.push({ kind: "file_edit", path: action.path, callId: id })
145
+ break
146
+ }
147
+ case "ObservationEvent": {
148
+ const obs = (e.observation ?? {}) as Ev
149
+ if (images(obs.content)) lost.add("images in tool results", images(obs.content))
150
+ const code = typeof obs.exit_code === "number" ? obs.exit_code : undefined
151
+ result(e, !obs.is_error && (code === undefined || code === 0 || code === -1), texts(obs.content))
152
+ break
153
+ }
154
+ case "AgentErrorEvent":
155
+ result(e, false, String(e.error ?? ""))
156
+ break
157
+ case "UserRejectObservation":
158
+ result(e, false, `Action rejected: ${String(e.rejection_reason ?? "")}`)
159
+ break
160
+ case "Condensation":
161
+ if (typeof e.summary === "string" && e.summary.trim()) turns.push({ kind: "compaction", ...(when ? { at: when } : {}), summary: e.summary })
162
+ break
163
+ case "CondensationRequest":
164
+ case "CondensationSummaryEvent":
165
+ case "ConversationStateUpdateEvent":
166
+ case "PauseEvent":
167
+ case "InterruptEvent":
168
+ case "TokenEvent":
169
+ case "StreamingDeltaEvent":
170
+ case "LLMCompletionLogEvent":
171
+ case "HookExecutionEvent":
172
+ case "SecurityAnalysisEvent":
173
+ case "ConversationErrorEvent":
174
+ break
175
+ default:
176
+ lost.add(`unknown event kinds: ${String(e.kind)}`)
177
+ }
178
+ }
179
+ lost.flush(h)
180
+ h.startedAt = first
181
+ h.updatedAt = last ?? mtime(dir)
182
+ const prompt = turns.find((t) => t.kind === "user") as Extract<Turn, { kind: "user" }> | undefined
183
+ if (prompt) h.title = prompt.text.replace(/\s+/g, " ").trim().slice(0, 80)
184
+ return { header: h, turns }
185
+ }
186
+
187
+ export const openhandsReader: Reader = {
188
+ tool: TOOL,
189
+ label: LABEL,
190
+
191
+ async list(home: string): Promise<SessionRef[]> {
192
+ const root = conversationsDir(home)
193
+ const refs: SessionRef[] = []
194
+ for (const id of ls(root)) {
195
+ const dir = join(root, id)
196
+ if (!isDir(dir)) continue
197
+ const files = eventFiles(dir)
198
+ // As the CLI's LocalFileStore lists: a folder with events whose first one has a timestamp.
199
+ const firstEvent = files[0] ? readJson<Ev>(files[0]) : undefined
200
+ if (!firstEvent?.timestamp) continue
201
+ const base = readJson<Ev>(join(dir, "base_state.json"))
202
+ const cwd = (base?.workspace?.working_dir as string | undefined) ?? (firstEvent.kind === "SystemPromptEvent" ? workingDir(texts([firstEvent.dynamic_context])) : undefined)
203
+ const lastEvent = readJson<Ev>(files[files.length - 1]!)
204
+ const title = firstPrompt(files)
205
+ refs.push({
206
+ tool: TOOL,
207
+ sourceId: id,
208
+ path: dir,
209
+ ...(title ? { title: title.replace(/\s+/g, " ").trim().slice(0, 80) } : {}),
210
+ ...(cwd ? { cwd } : {}),
211
+ updatedAt: eventTime(lastEvent?.timestamp) ?? eventTime(firstEvent.timestamp),
212
+ })
213
+ }
214
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
215
+ },
216
+
217
+ async read(ref: SessionRef, home: string): Promise<Session> {
218
+ const dir = ref.path || join(conversationsDir(home), ref.sourceId)
219
+ if (!isDir(dir)) throw new Error(`no OpenHands conversation at ${dir}`)
220
+ return readConversation(dir, ref.sourceId)
221
+ },
222
+ }