@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,136 @@
1
+ import { closeSync, openSync, readSync, realpathSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { versionOf } from "../../fsutil.ts"
4
+ import type { ToolKind } from "../../session/types.ts"
5
+ import { toolKindOf } from "../../session/util.ts"
6
+
7
+ // What OpenHands CLI conversations look like, as far as this adapter relies on it. Checked against the
8
+ // released CLI, openhands 1.16.0 on PyPI with openhands-sdk 1.21.0 (installed from PyPI; source read in
9
+ // site-packages: openhands_cli/conversations/store/local.py, openhands/sdk/conversation/{state,event_store,
10
+ // persistence_const}.py, openhands/sdk/event/**, openhands/sdk/agent/agent.py), and against events the SDK
11
+ // itself serialised (EventLog.append, model_dump_json(exclude_none=True)). Catalogue entry 15.
12
+ //
13
+ // A conversation is a folder ~/.openhands/conversations/<uuid hex>/ holding base_state.json (the
14
+ // ConversationState) and events/event-<5-digit index>-<event uuid>.json, one pydantic event per file with a
15
+ // "kind" discriminator. The CLI lists every folder whose events/ holds at least one event, dated by the first
16
+ // event's timestamp and titled by the first user MessageEvent; `openhands --resume <id>` builds the
17
+ // conversation from those files (ConversationState.create), and the model is sent the events in order:
18
+ // the SystemPromptEvent first, then messages, actions and observations, with Condensation events applied.
19
+
20
+ export const TOOL = "openhands"
21
+ export const LABEL = "OpenHands CLI"
22
+
23
+ /** OpenHands CLI versions whose conversation format the writer was checked against (and their SDK). */
24
+ export const TESTED_VERSIONS = ["1.16.0"]
25
+
26
+ export const openhandsDir = (home: string) => join(home, ".openhands")
27
+ export const conversationsDir = (home: string) => join(openhandsDir(home), "conversations")
28
+ export const agentSettingsPath = (home: string) => join(openhandsDir(home), "agent_settings.json")
29
+
30
+ /** persistence_const.EVENT_NAME_RE: exactly five digits of index, then the event id. */
31
+ export const EVENT_FILE = /^event-(\d{5})-([0-9a-fA-F-]{8,})\.json$/
32
+ export const eventFile = (index: number, id: string) => `event-${String(index).padStart(5, "0")}-${id}.json`
33
+
34
+ /** The note a continued conversation carries in its system prompt's dynamic context; the reader looks for it. */
35
+ export const continuedNote = (label: string, id: string, when: string) =>
36
+ `[This conversation continues ${label} session ${id}, carried over by brain session on ${when}. The events after this system prompt are its history; anything listed as lost did not carry over. Check files before relying on details, since they may have changed.]`
37
+ export const CONTINUED_NOTE = /\[This conversation continues .+? session (\S+), carried over by brain session/
38
+
39
+ /** "OpenHands CLI 1.16.0" → "1.16.0". */
40
+ export const openhandsVersion = (bin = "openhands") => versionOf(bin)?.match(/(\d+\.\d+\.\d+)/)?.[1]
41
+
42
+ const pad = (n: number, w = 2) => String(n).padStart(w, "0")
43
+ /**
44
+ * A timestamp as OpenHands writes it: datetime.now().isoformat(), local time without a zone. The CLI sorts
45
+ * conversations by it, and a zoned value next to its own naive ones would fail that comparison.
46
+ */
47
+ export function naiveLocal(ms: number): string {
48
+ const d = new Date(Math.floor(ms))
49
+ const micros = pad(d.getMilliseconds(), 3) + pad(Math.floor((ms % 1) * 1000), 3)
50
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${micros}`
51
+ }
52
+
53
+ /** A naive local or zoned OpenHands timestamp → ISO. */
54
+ export function eventTime(v: unknown): string | undefined {
55
+ if (typeof v !== "string" || !v) return undefined
56
+ const t = new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(v) ? v : v.slice(0, 23))
57
+ return Number.isFinite(t.getTime()) ? t.toISOString() : undefined
58
+ }
59
+
60
+ /** OpenHands' own tools, with what each does in portable terms. file_editor's kind depends on its command. */
61
+ export function openhandsKind(name: string, input: unknown): ToolKind {
62
+ if (name === "terminal" || name === "execute_bash") return "shell"
63
+ if (name === "task_tracker") return "todo"
64
+ if (name === "task_tool_set" || name === "task" || name === "delegate") return "task"
65
+ if (name === "think" || name === "finish") return "other"
66
+ if (name === "file_editor" || name === "str_replace_editor") {
67
+ const command = (input as { command?: unknown } | undefined)?.command
68
+ return command === "view" ? "read" : command === "create" ? "write" : "edit"
69
+ }
70
+ return toolKindOf(name)
71
+ }
72
+
73
+ /**
74
+ * The Python of a pip or uv install of the CLI: the interpreter named by the `openhands` script's shebang,
75
+ * either directly (#!/…/bin/python) or through pip's long-path form (#!/bin/sh then '''exec' '/…/python').
76
+ * A standalone binary has none.
77
+ */
78
+ export function openhandsPython(bin: string): string | undefined {
79
+ const path = bin.includes("/") ? bin : Bun.which(bin, { PATH: process.env.PATH ?? "" })
80
+ if (!path) return undefined
81
+ let head = ""
82
+ try {
83
+ const fd = openSync(realpathSync(path), "r")
84
+ const buf = Buffer.alloc(600)
85
+ const n = readSync(fd, buf, 0, buf.length, 0)
86
+ closeSync(fd)
87
+ head = buf.subarray(0, n).toString("utf8")
88
+ } catch {
89
+ return undefined
90
+ }
91
+ if (!head.startsWith("#!")) return undefined
92
+ const direct = head.match(/^#!\s*(\S*python[\d.]*)\s*\n/)?.[1]
93
+ if (direct) return direct
94
+ return head.match(/^'''exec' ['"]([^'"]*python[\d.]*)['"]/m)?.[1]
95
+ }
96
+
97
+ /**
98
+ * Run with the CLI's own Python: the system prompt a new conversation in `cwd` would start with, made the way
99
+ * the CLI makes it (AgentStore.load_from_disk for the person's agent settings, the CLI's agent context of
100
+ * project skills plus "Your current working directory is ..." / "User operating system ..."), without
101
+ * starting tools, MCP servers or a model. Public skills are left out: loading them fetches from the network.
102
+ */
103
+ export const RENDER_PROMPT_PY = `
104
+ import json, sys
105
+ from openhands.sdk import AgentContext
106
+ from openhands.sdk.context import load_project_skills
107
+ from openhands_cli.stores import AgentStore
108
+ from openhands_cli.utils import get_os_description
109
+ cwd = sys.argv[1]
110
+ agent = AgentStore().load_from_disk()
111
+ if agent is None:
112
+ print("BRAIN-RENDER " + json.dumps({"error": "no agent settings"}))
113
+ sys.exit(0)
114
+ suffix = "\\n".join([f"Your current working directory is: {cwd}", f"User operating system: {get_os_description()}"])
115
+ ctx = AgentContext(skills=load_project_skills(cwd), system_message_suffix=suffix, load_user_skills=True, load_public_skills=False)
116
+ agent = agent.model_copy(update={"agent_context": ctx})
117
+ dynamic = ctx.get_system_message_suffix(llm_model=agent.llm.model, llm_model_canonical=agent.llm.model_canonical_name)
118
+ print("BRAIN-RENDER " + json.dumps({"system_prompt": agent.static_system_message, "dynamic_context": dynamic, "model": agent.llm.model}))
119
+ `
120
+
121
+ export interface RenderedPrompt {
122
+ system_prompt: string
123
+ dynamic_context?: string | null
124
+ model?: string
125
+ }
126
+
127
+ /** The system prompt from the installed CLI, run against `home` (its agent settings) and `cwd` (its skills). */
128
+ export function renderPrompt(python: string, home: string, cwd: string): RenderedPrompt {
129
+ const env: Record<string, string> = { HOME: home, PATH: process.env.PATH ?? "/usr/bin:/bin", OPENHANDS_SUPPRESS_BANNER: "1", OPENHANDS_WORK_DIR: cwd, LANG: process.env.LANG ?? "en_US.UTF-8" }
130
+ const p = Bun.spawnSync([python, "-I", "-c", RENDER_PROMPT_PY, cwd], { cwd, env, stdout: "pipe", stderr: "pipe", timeout: 120_000 })
131
+ const line = p.stdout.toString().split("\n").find((l) => l.startsWith("BRAIN-RENDER "))
132
+ if (!line) throw new Error(`OpenHands could not build its system prompt (exit ${p.exitCode}): ${p.stderr.toString().trim().split("\n").slice(-3).join(" ")}`)
133
+ const out = JSON.parse(line.slice("BRAIN-RENDER ".length)) as RenderedPrompt & { error?: string }
134
+ if (out.error || !out.system_prompt) throw new Error(`OpenHands could not build its system prompt: ${out.error ?? "empty prompt"}`)
135
+ return out
136
+ }
@@ -0,0 +1,404 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { extname, join } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, isDir } from "../../fsutil.ts"
6
+ import type { Session, Turn } from "../../session/types.ts"
7
+ import { countTurns, unpaired } from "../../session/util.ts"
8
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
9
+ import { readConversation } from "./reader.ts"
10
+ import {
11
+ LABEL,
12
+ type RenderedPrompt,
13
+ TESTED_VERSIONS,
14
+ TOOL,
15
+ agentSettingsPath,
16
+ continuedNote,
17
+ conversationsDir,
18
+ eventFile,
19
+ naiveLocal,
20
+ openhandsPython,
21
+ openhandsVersion,
22
+ renderPrompt,
23
+ } from "./records.ts"
24
+
25
+ // Continues a portable session in the OpenHands CLI as a new conversation: a folder under
26
+ // ~/.openhands/conversations/<uuid hex>/events/ holding the events the SDK itself would have stored, in its
27
+ // serialisation (kind discriminators, naive local timestamps, five-digit indexes). `openhands --resume <id>`
28
+ // lists and opens it: with no base_state.json yet, ConversationState.create takes its fresh path, loads the
29
+ // events through EventLog and writes base_state.json from the CLI's current agent, as for any new
30
+ // conversation; nothing has to agree with an agent snapshot written here.
31
+ //
32
+ // The first event is the SystemPromptEvent, because the agent refuses a log whose first events hold a user
33
+ // message without one (Agent.init_state), and the model is sent the events in order. Its text is made by the
34
+ // installed CLI from the person's own agent settings (records.ts RENDER_PROMPT_PY), so the model gets the
35
+ // OpenHands prompt it would get in any conversation. The note saying where the session came from goes in
36
+ // that event's dynamic context, OpenHands' per-conversation part of the system prompt, not in a user message.
37
+ //
38
+ // Tool calls stay structural: ActionEvent + ObservationEvent paired by tool_call_id, parallel calls sharing
39
+ // an llm_response_id. Shell, read, write and edit calls become OpenHands' own terminal and file_editor calls
40
+ // (TerminalAction/TerminalObservation, FileEditorAction/FileEditorObservation); others keep their name with
41
+ // no executable action and a generic MCPToolObservation, the SDK's record for a named tool's text result.
42
+ // A failed call is an AgentErrorEvent. Readable reasoning rides on the next agent event's reasoning_content,
43
+ // a plain-text field OpenHands keeps and sends back only to models that take it. A compaction is a native
44
+ // Condensation: the turns before it stay on screen, the model gets the system prompt, the summary and what
45
+ // follows.
46
+
47
+ type Ev = Record<string, unknown>
48
+ type Call = Extract<Turn, { kind: "tool_call" }>
49
+ type Result = Extract<Turn, { kind: "tool_result" }>
50
+
51
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
52
+ const text = (t: string) => ({ cache_prompt: false, type: "text", text: t })
53
+
54
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
55
+ const IMAGE_LIMIT = 5 * 1024 * 1024
56
+
57
+ const pick = (o: Record<string, unknown>, keys: string[]) => {
58
+ for (const k of keys) if (typeof o[k] === "string") return o[k] as string
59
+ return undefined
60
+ }
61
+
62
+ /** The exit code a failed command's output states ("Exit code 2", "exited with code 2"), if it states one. */
63
+ export const exitCodeOf = (out: string) => {
64
+ const m = out.match(/exit(?:ed with)?(?: status| code)?:?\s*(-?\d+)\b/i)
65
+ return m && Number(m[1]) !== 0 ? Number(m[1]) : undefined
66
+ }
67
+
68
+ /**
69
+ * The call as OpenHands' own tool where one fits: its name, arguments and executable action, and the
70
+ * observation kind its result is stored as. Anything else keeps its own name with no action.
71
+ */
72
+ export function mapCall(call: Call, cwd?: string): { name: string; args: Record<string, unknown> | string; action?: Ev; observe: (ok: boolean, out: string) => Ev; native: boolean } {
73
+ const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Record<string, unknown>) : undefined
74
+ if (input) {
75
+ const command = pick(input, ["command", "cmd"]) ?? (Array.isArray(input.command) ? (input.command as unknown[]).map(String).join(" ") : undefined)
76
+ if (call.toolKind === "shell" && command) {
77
+ return {
78
+ name: "terminal",
79
+ args: { command },
80
+ action: { command, is_input: false, reset: false, kind: "TerminalAction" },
81
+ // A success is exit code 0. A failure takes the code its output states, else 1: OpenHands reads -1 as a
82
+ // command still running, which would misstate a failed one (the writer lists how many it guessed).
83
+ observe: (ok, out) => {
84
+ const code = ok ? 0 : (exitCodeOf(out) ?? 1)
85
+ return { content: [text(out)], is_error: !ok, command, exit_code: code, timeout: false, metadata: { exit_code: code, pid: -1, ...(cwd ? { working_dir: cwd } : {}), prefix: "", suffix: `\n[The command completed with exit code ${code}.]` }, kind: "TerminalObservation" }
86
+ },
87
+ native: true,
88
+ }
89
+ }
90
+ const path = pick(input, ["file_path", "path", "filePath", "absolute_path", "target_file", "file"])
91
+ const editor = (command: string, extra: Record<string, unknown>) => ({
92
+ name: "file_editor",
93
+ args: { command, path, ...extra },
94
+ action: { command, path, ...extra, kind: "FileEditorAction" },
95
+ observe: (ok: boolean, out: string) => ({ content: [text(out)], is_error: !ok, command, path, prev_exist: true, kind: "FileEditorObservation" }),
96
+ native: true,
97
+ })
98
+ if (path && call.toolKind === "read") return editor("view", {})
99
+ const content = pick(input, ["content", "file_text", "contents"])
100
+ if (path && call.toolKind === "write" && content !== undefined) return editor("create", { file_text: content })
101
+ const oldStr = pick(input, ["old_string", "old_str", "oldText", "old_text"])
102
+ const newStr = pick(input, ["new_string", "new_str", "newText", "new_text"])
103
+ if (path && call.toolKind === "edit" && oldStr !== undefined && newStr !== undefined) return editor("str_replace", { old_str: oldStr, new_str: newStr })
104
+ }
105
+ // Names the model APIs accept: letters, digits, _ and -, at most 64.
106
+ const name = call.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
107
+ return {
108
+ name,
109
+ args: typeof call.input === "string" ? call.input : (input ?? { input: call.input ?? null }),
110
+ observe: (ok, out) => ({ content: [text(out)], is_error: !ok, tool_name: name, kind: "MCPToolObservation" }),
111
+ native: false,
112
+ }
113
+ }
114
+
115
+ /** The conversation regrouped: messages, agent responses (thought plus the calls made together), compactions. */
116
+ type Step =
117
+ | { kind: "user"; at?: string; content: Ev[] }
118
+ | { kind: "agent"; at?: string; texts: string[]; reasoning?: string; calls: Call[] }
119
+ | { kind: "compaction"; at?: string; summary: string }
120
+
121
+ export interface EventPlan {
122
+ /** Every event of the new conversation, in index order. */
123
+ events: Ev[]
124
+ lost: string[]
125
+ /** What a read-back must find. */
126
+ expect: { users: number; calls: number }
127
+ }
128
+
129
+ /** The events of a new OpenHands conversation continuing `session`. Pure apart from reading attached images. */
130
+ export function planEvents(session: Session, opts: { now: Date; prompt: RenderedPrompt; cwd?: string }): EventPlan {
131
+ const drops = new Map<string, number>()
132
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
133
+ const label = toolLabel(session.header.tool)
134
+ const results = new Map<string, Result>()
135
+ const callIds = new Set<string>()
136
+ for (const t of session.turns) {
137
+ if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
138
+ if (t.kind === "tool_call") callIds.add(t.id)
139
+ }
140
+
141
+ const steps: Step[] = []
142
+ let cur: Extract<Step, { kind: "agent" }> | undefined
143
+ let reasoning: string[] = []
144
+ const flush = () => {
145
+ if (cur && (cur.texts.length || cur.calls.length)) steps.push(cur)
146
+ cur = undefined
147
+ }
148
+ const agent = (at?: string) => {
149
+ if (!cur) {
150
+ cur = { kind: "agent", ...(at ? { at } : {}), texts: [], calls: [] }
151
+ if (reasoning.length) cur.reasoning = reasoning.join("\n\n")
152
+ reasoning = []
153
+ }
154
+ return cur
155
+ }
156
+ const dropReasoning = () => {
157
+ if (reasoning.length) drop("reasoning with no reply after it", reasoning.length)
158
+ reasoning = []
159
+ }
160
+ const seen = new Set<string>()
161
+ for (const t of session.turns) {
162
+ switch (t.kind) {
163
+ case "user": {
164
+ flush()
165
+ dropReasoning()
166
+ const content: Ev[] = []
167
+ let body = t.text
168
+ for (const a of t.attachments ?? []) {
169
+ if (!a.path) continue
170
+ if (a.kind === "file") {
171
+ body += `\n\n[Attached file: ${a.path}]`
172
+ continue
173
+ }
174
+ const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
175
+ let size = Number.POSITIVE_INFINITY
176
+ try {
177
+ size = statSync(a.path).size
178
+ } catch {}
179
+ if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT)
180
+ content.push({ cache_prompt: false, type: "image", image_urls: [`data:${mime};base64,${readFileSync(a.path).toString("base64")}`] })
181
+ else drop("images that could not be attached")
182
+ }
183
+ if (!body.trim() && !content.length) break
184
+ steps.push({ kind: "user", ...(t.at ? { at: t.at } : {}), content: [text(body.trim() ? body : "(image)"), ...content] })
185
+ break
186
+ }
187
+ case "assistant":
188
+ // Text after a tool call is a new response: it was written after the call's result came back.
189
+ if (cur?.calls.length) flush()
190
+ if (t.text.trim()) agent(t.at).texts.push(t.text)
191
+ break
192
+ case "tool_call":
193
+ if (seen.has(t.id)) break
194
+ seen.add(t.id)
195
+ agent(t.at).calls.push(t)
196
+ break
197
+ case "tool_result":
198
+ // A result closes the response it answers; a call after it is a new response, not a parallel one.
199
+ if (!callIds.has(t.callId)) drop("tool results without a call")
200
+ else if (cur?.calls.some((c) => c.id === t.callId)) flush()
201
+ break
202
+ case "reasoning":
203
+ if (cur?.calls.length || cur?.texts.length) flush()
204
+ if (t.text.trim()) reasoning.push(t.text)
205
+ break
206
+ case "compaction":
207
+ flush()
208
+ dropReasoning()
209
+ steps.push({ kind: "compaction", ...(t.at ? { at: t.at } : {}), summary: `[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${t.summary}` })
210
+ break
211
+ case "file_edit":
212
+ if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
213
+ break
214
+ case "subagent":
215
+ drop("subagent sessions (their results stay in the parent's tool results)")
216
+ break
217
+ }
218
+ }
219
+ flush()
220
+ dropReasoning()
221
+
222
+ // Events, with timestamps that never go backwards and call ids every model API accepts.
223
+ const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
224
+ let clock = (known ?? opts.now.getTime()) - 1
225
+ const stamp = (at?: string) => {
226
+ const t = at ? Date.parse(at) : Number.NaN
227
+ clock = Number.isFinite(t) && t > clock ? t : clock + 0.001
228
+ return naiveLocal(clock)
229
+ }
230
+ const ids = new Map<string, string>()
231
+ const usedIds = new Set<string>()
232
+ let remapped = 0
233
+ const toolCallId = (id: string) => {
234
+ let v = ids.get(id)
235
+ if (v) return v
236
+ const base = /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${id.replace(/[^A-Za-z0-9]/g, "").slice(-40) || "x"}`
237
+ v = base
238
+ for (let n = 2; usedIds.has(v); n++) v = `${base}_${n}`
239
+ ids.set(id, v)
240
+ usedIds.add(v)
241
+ if (v !== id) remapped++
242
+ return v
243
+ }
244
+
245
+ const events: Ev[] = []
246
+ const push = (fields: Ev, at?: string) => {
247
+ const e = { id: randomUUID(), timestamp: stamp(at), ...fields }
248
+ events.push(e)
249
+ return e
250
+ }
251
+ const dynamic = [opts.prompt.dynamic_context, continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))].filter(Boolean).join("\n\n")
252
+ push({ source: "agent", system_prompt: text(opts.prompt.system_prompt), tools: [], dynamic_context: text(dynamic), kind: "SystemPromptEvent" }, undefined)
253
+ // Ids of what the model has been shown since the system prompt, for a Condensation to forget.
254
+ let shown: string[] = []
255
+ const expect = { users: 0, calls: 0 }
256
+ let renamed = 0
257
+ for (const s of steps) {
258
+ if (s.kind === "user") {
259
+ shown.push(push({ source: "user", llm_message: { role: "user", content: s.content, thinking_blocks: [] }, activated_skills: [], extended_content: [], kind: "MessageEvent" }, s.at).id as string)
260
+ expect.users++
261
+ continue
262
+ }
263
+ if (s.kind === "compaction") {
264
+ const e = push({ source: "environment", forgotten_event_ids: shown, summary: s.summary, summary_offset: 1, llm_response_id: randomUUID(), kind: "Condensation" }, s.at)
265
+ shown = [`${e.id}-summary`]
266
+ continue
267
+ }
268
+ const response = randomUUID()
269
+ const thought = s.texts.map(text)
270
+ if (!s.calls.length) {
271
+ shown.push(
272
+ push(
273
+ { source: "agent", llm_message: { role: "assistant", content: thought, ...(s.reasoning ? { reasoning_content: s.reasoning } : {}), thinking_blocks: [] }, llm_response_id: response, activated_skills: [], extended_content: [], kind: "MessageEvent" },
274
+ s.at,
275
+ ).id as string,
276
+ )
277
+ continue
278
+ }
279
+ const actions: Array<{ call: Call; event: Ev; mapped: ReturnType<typeof mapCall>; id: string }> = []
280
+ s.calls.forEach((call, k) => {
281
+ const mapped = mapCall(call, opts.cwd)
282
+ if (!mapped.native && mapped.name !== call.name) renamed++
283
+ const id = toolCallId(call.id)
284
+ const args = typeof mapped.args === "string" ? mapped.args : JSON.stringify(mapped.args)
285
+ // Only the first action of a response carries its thought and reasoning (_combine_action_events).
286
+ const event = push(
287
+ {
288
+ source: "agent",
289
+ thought: k === 0 ? thought : [],
290
+ ...(k === 0 && s.reasoning ? { reasoning_content: s.reasoning } : {}),
291
+ thinking_blocks: [],
292
+ ...(mapped.action ? { action: mapped.action } : {}),
293
+ tool_name: mapped.name,
294
+ tool_call_id: id,
295
+ tool_call: { id, name: mapped.name, arguments: args, origin: "completion" },
296
+ llm_response_id: response,
297
+ security_risk: "UNKNOWN",
298
+ kind: "ActionEvent",
299
+ },
300
+ call.at ?? s.at,
301
+ )
302
+ shown.push(event.id as string)
303
+ actions.push({ call, event, mapped, id })
304
+ expect.calls++
305
+ })
306
+ for (const a of actions) {
307
+ const r = results.get(a.call.id)
308
+ let out = r?.output ?? ""
309
+ if (r?.fullPath) out += `\n\n[Full output: ${r.fullPath}]`
310
+ if (r && !out.trim()) out = "(no output)"
311
+ const fields: Ev = !r
312
+ ? { source: "agent", tool_name: a.mapped.name, tool_call_id: a.id, error: "No result was recorded for this call; the session moved on before it finished.", kind: "AgentErrorEvent" }
313
+ : !r.ok && !a.mapped.native
314
+ ? { source: "agent", tool_name: a.mapped.name, tool_call_id: a.id, error: out, kind: "AgentErrorEvent" }
315
+ : { source: "environment", tool_name: a.mapped.name, tool_call_id: a.id, observation: a.mapped.observe(r.ok, out), action_id: a.event.id, kind: "ObservationEvent" }
316
+ if (!r) drop("tool calls without a result (closed as errors)")
317
+ else if (!r.ok && a.mapped.name === "terminal" && exitCodeOf(out) === undefined) drop("exit codes of failed shell commands, recorded as 1")
318
+ shown.push(push(fields, r?.at ?? s.at).id as string)
319
+ }
320
+ }
321
+ if (!expect.users && !expect.calls && !steps.some((s) => s.kind === "agent")) throw new Error("nothing OpenHands can replay: the session has no messages or tool calls")
322
+
323
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
324
+ if (remapped) lost.push(`tool call ids, remapped to ids model APIs accept (${remapped})`)
325
+ if (renamed) lost.push(`tool names changed to fit model APIs (${renamed})`)
326
+ lost.push("OpenHands public skills (not listed in this conversation's system prompt)")
327
+ return { events, lost, expect }
328
+ }
329
+
330
+ /** The resolved folder (/private/tmp, not /tmp): the path OpenHands itself would record for the workspace. */
331
+ function realCwd(cwd: string): string {
332
+ try {
333
+ return realpathSync(cwd)
334
+ } catch {
335
+ return cwd
336
+ }
337
+ }
338
+
339
+ /**
340
+ * The OpenHands writer. `bin`, `version` and `render` are for tests and harnesses: the CLI to run, its
341
+ * version, and what builds the system prompt (the installed CLI's own code by default).
342
+ */
343
+ export function openhandsWriter(opts: { bin?: string; version?: string; render?: (home: string, cwd: string) => RenderedPrompt } = {}): Writer {
344
+ const bin = opts.bin ?? "openhands"
345
+ const installed = () => opts.version ?? openhandsVersion(bin)
346
+ const render =
347
+ opts.render ??
348
+ ((home: string, cwd: string) => {
349
+ const python = openhandsPython(bin)
350
+ if (!python) throw new Error("the OpenHands CLI's Python was not found")
351
+ return renderPrompt(python, home, cwd)
352
+ })
353
+ return {
354
+ tool: TOOL,
355
+ label: LABEL,
356
+ way: "native",
357
+
358
+ async ready(w: WriteOptions): Promise<Readiness> {
359
+ const v = installed()
360
+ if (!v) return { ok: false, why: "the OpenHands CLI is not installed (`openhands --version` did not run)" }
361
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `OpenHands CLI ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
362
+ if (!opts.render && !openhandsPython(bin))
363
+ return { ok: false, why: "the OpenHands CLI is a standalone binary here; the writer needs the Python install (`uv tool install openhands`) to build OpenHands' system prompt" }
364
+ if (!exists(agentSettingsPath(w.home))) return { ok: false, why: "OpenHands has no agent settings yet (~/.openhands/agent_settings.json); run `openhands` once to choose a model" }
365
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; OpenHands works in the folder a conversation belongs to` }
366
+ return { ok: true }
367
+ },
368
+
369
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
370
+ const cwd = realCwd(w.cwd)
371
+ let id = randomUUID().replace(/-/g, "")
372
+ while (exists(join(conversationsDir(w.home), id))) id = randomUUID().replace(/-/g, "")
373
+ const dir = join(conversationsDir(w.home), id)
374
+ // A dry run never runs OpenHands: the prompt does not change which files are written.
375
+ const prompt = w.dryRun ? { system_prompt: "(made by OpenHands when written)" } : render(w.home, cwd)
376
+ const plan = planEvents(session, { now: new Date(), prompt, cwd })
377
+ const files = plan.events.map((e, i) => join(dir, "events", eventFile(i, e.id as string)))
378
+ if (!w.dryRun) {
379
+ mkdirSync(join(dir, "events"), { recursive: true })
380
+ plan.events.forEach((e, i) => writeFileSync(files[i]!, JSON.stringify(e), { flag: "wx" }))
381
+ const back = readConversation(dir, id)
382
+ const n = countTurns(back)
383
+ const pairs = unpaired(back)
384
+ if (n.user !== plan.expect.users || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
385
+ rmSync(dir, { recursive: true, force: true })
386
+ throw new Error(
387
+ `the OpenHands conversation did not read back as written (users ${n.user}/${plan.expect.users}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
388
+ )
389
+ }
390
+ }
391
+ return {
392
+ tool: TOOL,
393
+ way: "native",
394
+ sessionId: id,
395
+ written: files,
396
+ open: `cd ${shellQuote(cwd)} && openhands --resume ${id}`,
397
+ lost: plan.lost,
398
+ dryRun: !!w.dryRun,
399
+ }
400
+ },
401
+ }
402
+ }
403
+
404
+ export const openhandsNativeWriter = openhandsWriter()
@@ -0,0 +1,10 @@
1
+ import { continueReader } from "../continue/reader.ts"
2
+ import { PEARAI } from "../continue/records.ts"
3
+ import { continueWriter } from "../continue/writer.ts"
4
+ import type { Reader, Writer } from "../types.ts"
5
+
6
+ // PearAI's chat, a fork of an older Continue: the same session store under ~/.pearai/sessions, with text-only
7
+ // messages, so the Continue adapter does the work with PearAI's settings (records.ts, PEARAI).
8
+ export const reader = continueReader(PEARAI)
9
+ export const readers: Reader[] = [reader]
10
+ export const writers: Writer[] = [continueWriter(PEARAI)]
@@ -0,0 +1,28 @@
1
+ import { join } from "node:path"
2
+ import type { Reader, Writer } from "../types.ts"
3
+ import { piFamilyReader } from "./reader.ts"
4
+ import type { PiFlavor } from "./records.ts"
5
+ import { piFamilyWriter } from "./writer.ts"
6
+
7
+ // Pi coding agent (earendil-works/pi, formerly badlogic/pi-mono): reads its sessions and continues any
8
+ // session as a new native Pi session, `pi --session <id>`.
9
+
10
+ /** Pi versions whose session format this writer was checked against (format version 3). */
11
+ export const PI_TESTED_VERSIONS = ["1.5.0"]
12
+
13
+ export const piFlavor: PiFlavor = {
14
+ tool: "pi",
15
+ label: "Pi",
16
+ bin: "pi",
17
+ // ~/.pi/agent/sessions; PI_CODING_AGENT_DIR and --session-dir move it, which brain does not follow.
18
+ sessionsRoot: (home) => join(home, ".pi", "agent", "sessions"),
19
+ // <start time with : and . as ->_<id>.jsonl, as SessionManager.newSession names it.
20
+ fileName: (id, startedAt) => `${startedAt.replace(/[:.]/g, "-")}_${id}.jsonl`,
21
+ newId: () => Bun.randomUUIDv7(),
22
+ }
23
+
24
+ export const piReader = piFamilyReader(piFlavor)
25
+ export const piWriter = (version?: string) => piFamilyWriter(piFlavor, { tested: PI_TESTED_VERSIONS, ...(version ? { version } : {}) })
26
+
27
+ export const readers: Reader[] = [piReader]
28
+ export const writers: Writer[] = [piWriter()]