@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,184 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { exists, gitInfo, ls, mtime } from "../../fsutil.ts"
4
+ import type { Session, Turn } from "../../session/types.ts"
5
+ import { clip, header, iso, lossCounter, noteModel } from "../../session/util.ts"
6
+ import { CONTINUED_NOTE } from "../claude-code/records.ts"
7
+ import { patchFiles } from "../codex/records.ts"
8
+ import type { Reader, SessionRef } from "../types.ts"
9
+ import { type Event, kindOf, parseEvents, personText, sessionsRoot } from "./records.ts"
10
+
11
+ // Reads Factory Droid sessions: ~/.factory/sessions/<project key>/<uuid>.jsonl. Messages are read in file
12
+ // order, as Droid loads them. Notices only the person saw (visibility "user_only", hook runs) and context
13
+ // only the model saw ("llm_only", e.g. reminders) are left out; the note a brain writer adds is recognised.
14
+ // Each LLM compaction becomes a compaction turn right after the message it summarised up to, with every
15
+ // earlier turn kept in front of it. Droid's provider-switch serialisations are skipped: they restate the
16
+ // messages that are still in the file.
17
+
18
+ const PEEK = 64 * 1024
19
+ const DEFAULT_TITLE = "New Session"
20
+
21
+ type Lost = ReturnType<typeof lossCounter>
22
+
23
+ /** The text of a content list, counting images it cannot keep. */
24
+ function textOf(content: unknown, lost?: Lost, what = "images"): string {
25
+ if (typeof content === "string") return content
26
+ if (!Array.isArray(content)) return ""
27
+ const parts: string[] = []
28
+ for (const b of content as Event[]) {
29
+ if (b?.type === "text" && typeof b.text === "string") parts.push(b.text)
30
+ else if (b?.type === "image") lost?.add(what)
31
+ else if (b?.type === "document") lost?.add("attached documents")
32
+ }
33
+ return parts.join("\n")
34
+ }
35
+
36
+ /** Whether a message is part of the conversation the model and the person both saw. */
37
+ const shared = (m: Event) => m.visibility !== "user_only" && m.visibility !== "llm_only" && m.hiddenFromUserViews !== true && m.hookEventName === undefined
38
+
39
+ /** One Droid session file as the portable session. */
40
+ export function parseSession(text: string, path: string, home?: string): Session {
41
+ const events = parseEvents(text)
42
+ if (!events.length) throw new Error(`${path} is not a Factory Droid session (no session_start line)`)
43
+ const start = events.find((e) => e.type === "session_start")!
44
+ const id = String(start.id)
45
+ const cwd = typeof start.cwd === "string" && start.cwd ? start.cwd : undefined
46
+ const lost = lossCounter()
47
+ const turns: Turn[] = []
48
+ const firstAt = events.find((e) => e.type === "message" && iso(e.timestamp))?.timestamp
49
+ const h = header("factory-droid", id, { sourcePath: path, project: { ...(cwd ? { cwd, ...gitInfo(cwd, home) } : {}) }, ...(iso(firstAt) ? { startedAt: iso(firstAt) } : {}) })
50
+
51
+ const messages = events.filter((e) => e.type === "message" && e.message && typeof e.message === "object")
52
+ // LLM compactions by the message they summarised up to; one with no anchor summarises a previous session.
53
+ const after = new Map<number, Event[]>()
54
+ for (const c of events) {
55
+ if (c.type !== "compaction_state" || (c.summaryKind ?? "llm_summary") !== "llm_summary" || typeof c.summaryText !== "string") continue
56
+ let k = typeof c.anchorMessage?.id === "string" ? messages.findIndex((m) => m.id === c.anchorMessage.id) : -1
57
+ if (k < 0 && typeof c.anchorMessage?.index === "number" && c.anchorMessage.index < messages.length) k = c.anchorMessage.index
58
+ after.set(k, [...(after.get(k) ?? []), c])
59
+ }
60
+ const compaction = (c: Event) => {
61
+ if (c.summaryText.trim()) turns.push({ kind: "compaction", ...at(c.timestamp), summary: c.summaryText })
62
+ }
63
+
64
+ const calls = new Map<string, Extract<Turn, { kind: "tool_call" }>>()
65
+ let continued: string | undefined
66
+ let last: string | undefined
67
+ for (const c of after.get(-1) ?? []) compaction(c)
68
+ for (const [i, e] of messages.entries()) {
69
+ if (typeof e.timestamp === "string") last = e.timestamp
70
+ const m = e.message as Event
71
+ const stamp = at(e.timestamp)
72
+ if (m.visibility === "llm_only") {
73
+ const note = textOf(m.content).match(CONTINUED_NOTE)
74
+ if (note) continued = note[1]
75
+ } else if (shared(m)) {
76
+ if (m.role === "assistant") assistant(m, stamp)
77
+ else user(m, stamp)
78
+ }
79
+ for (const c of after.get(i) ?? []) compaction(c)
80
+ }
81
+
82
+ function assistant(m: Event, stamp: { at?: string }) {
83
+ const model = typeof m.modelId === "string" ? m.modelId : undefined
84
+ noteModel(h, model)
85
+ for (const b of Array.isArray(m.content) ? (m.content as Event[]) : [{ type: "text", text: String(m.content ?? "") }]) {
86
+ if (b?.type === "text" && typeof b.text === "string" && b.text.trim()) turns.push({ kind: "assistant", ...stamp, text: b.text, ...(model ? { model } : {}) })
87
+ else if (b?.type === "thinking") {
88
+ if (typeof b.thinking === "string" && b.thinking.trim()) turns.push({ kind: "reasoning", ...stamp, text: b.thinking })
89
+ else if (b.signature) lost.add("signed reasoning")
90
+ } else if (b?.type === "redacted_thinking") lost.add("redacted reasoning")
91
+ else if (b?.type === "tool_use" && typeof b.id === "string") {
92
+ // Droid renames MultiEdit to Edit when it loads a session.
93
+ const name = b.name === "MultiEdit" ? "Edit" : typeof b.name === "string" ? b.name : "tool"
94
+ const call: Extract<Turn, { kind: "tool_call" }> = { kind: "tool_call", ...stamp, id: b.id, name, toolKind: kindOf(name), input: b.input ?? {} }
95
+ calls.set(b.id, call)
96
+ turns.push(call)
97
+ } else if (b?.type === "image") lost.add("images")
98
+ }
99
+ }
100
+
101
+ function user(m: Event, stamp: { at?: string }) {
102
+ const blocks = Array.isArray(m.content) ? (m.content as Event[]) : [{ type: "text", text: String(m.content ?? "") }]
103
+ for (const b of blocks) {
104
+ if (b?.type !== "tool_result" || typeof b.tool_use_id !== "string") continue
105
+ const full = clip(textOf(b.content, lost, "tool result images"))
106
+ const ok = b.is_error !== true
107
+ turns.push({ kind: "tool_result", ...stamp, callId: b.tool_use_id, ok, output: full.text, ...(full.truncated ? { truncated: true } : {}) })
108
+ const call = calls.get(b.tool_use_id)
109
+ const input = (call?.input ?? {}) as Event
110
+ if (!ok || !call) continue
111
+ if ((call.name === "Edit" || call.name === "Create") && typeof input.file_path === "string") turns.push({ kind: "file_edit", ...stamp, path: input.file_path, callId: call.id })
112
+ else if (call.name === "ApplyPatch" && typeof input.input === "string")
113
+ for (const f of patchFiles(input.input)) turns.push({ kind: "file_edit", ...stamp, path: f.path, patch: f.patch, callId: call.id })
114
+ }
115
+ // Text beside tool results is what the person typed while the tools ran; reminders are not theirs.
116
+ const text = textOf(blocks.filter((b) => b?.type !== "tool_result"), lost)
117
+ if (personText(text)) turns.push({ kind: "user", ...stamp, text: personText(text) })
118
+ }
119
+
120
+ const title = typeof start.title === "string" && start.title.trim() && start.title !== DEFAULT_TITLE ? start.title.trim() : undefined
121
+ h.title = title ?? (turns.find((t) => t.kind === "user") as Extract<Turn, { kind: "user" }> | undefined)?.text.replace(/\s+/g, " ").trim().slice(0, 80)
122
+ if (!h.title) delete h.title
123
+ const updated = iso(last)
124
+ if (updated) h.updatedAt = updated
125
+ if (continued) h.parent = { id: continued, kind: "continued" }
126
+ else if (typeof start.parent === "string" && start.parent) h.parent = { id: `factory-droid:${start.parent}`, kind: "fork" }
127
+ else if (typeof start.callingSessionId === "string" && start.callingSessionId) h.parent = { id: `factory-droid:${start.callingSessionId}`, kind: "subagent" }
128
+ lost.flush(h)
129
+ return { header: h, turns }
130
+ }
131
+
132
+ function at(v: unknown): { at?: string } {
133
+ const t = iso(v)
134
+ return t ? { at: t } : {}
135
+ }
136
+
137
+ /** The session_start line and the first request, from the start and end of a file; undefined for a file with no messages. */
138
+ async function peek(path: string): Promise<Omit<SessionRef, "tool" | "path"> | undefined> {
139
+ const file = Bun.file(path)
140
+ const text = file.size <= 4 * PEEK ? await file.text() : `${await file.slice(0, PEEK).text()}\n${await file.slice(file.size - PEEK).text()}`
141
+ const events = parseEvents(text)
142
+ const start = events.find((e) => e.type === "session_start")
143
+ // Droid's picker leaves out empty sessions and mission workers.
144
+ if (!start || start.decompSessionType === "worker" || !events.some((e) => e.type === "message")) return undefined
145
+ const first = events.find((e) => e.type === "message" && e.message?.role === "user" && shared(e.message) && !(e.message.content as Event[] | undefined)?.some?.((b) => b?.type === "tool_result"))
146
+ const named = typeof start.title === "string" && start.title.trim() && start.title !== DEFAULT_TITLE ? start.title.trim() : undefined
147
+ const title = named ?? (first ? textOf(first.message.content).replace(/\s+/g, " ").trim().slice(0, 80) || undefined : undefined)
148
+ return {
149
+ sourceId: String(start.id),
150
+ ...(title ? { title } : {}),
151
+ ...(typeof start.cwd === "string" && start.cwd ? { cwd: start.cwd } : {}),
152
+ ...(mtime(path) ? { updatedAt: mtime(path) } : {}),
153
+ }
154
+ }
155
+
156
+ export const factoryDroidReader: Reader = {
157
+ tool: "factory-droid",
158
+ label: "Factory Droid",
159
+
160
+ async list(home: string): Promise<SessionRef[]> {
161
+ const root = sessionsRoot(home)
162
+ const refs: SessionRef[] = []
163
+ for (const project of ls(root)) {
164
+ const dir = join(root, project)
165
+ for (const name of ls(dir)) {
166
+ if (!name.endsWith(".jsonl")) continue
167
+ const path = join(dir, name)
168
+ try {
169
+ if (!statSync(path).isFile()) continue
170
+ const p = await peek(path)
171
+ if (p) refs.push({ tool: "factory-droid", path, ...p })
172
+ } catch {
173
+ // Deleted or unreadable between listing and reading: not a session to offer.
174
+ }
175
+ }
176
+ }
177
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
178
+ },
179
+
180
+ async read(ref: SessionRef, home: string): Promise<Session> {
181
+ if (!exists(ref.path)) throw new Error(`no Factory Droid session at ${ref.path}`)
182
+ return parseSession(readFileSync(ref.path, "utf8"), ref.path, home)
183
+ },
184
+ }
@@ -0,0 +1,79 @@
1
+ import { realpathSync } from "node:fs"
2
+ import { join, resolve } from "node:path"
3
+ import type { ToolKind } from "../../session/types.ts"
4
+ import { toolKindOf } from "../../session/util.ts"
5
+
6
+ // Facts about Factory Droid's session files, read from the JavaScript bundled in the droid 1.6.0 binary
7
+ // (the session store's loader, the message serializer and the tool definitions). Droid is closed source;
8
+ // see docs/sessions/2026-11-08-session-formats.md, entry 51.
9
+ //
10
+ // ~/.factory/sessions/<project key>/<uuid>.jsonl (FACTORY_HOME_OVERRIDE moves ~/.factory's parent, which
11
+ // brain does not follow). The first line is {type:"session_start", id, title, owner, version:2, cwd}; a file
12
+ // without one is not a session. Then one event per line: "message" events hold Anthropic-style messages
13
+ // ({role:"user"|"assistant", content:[text|image|thinking|redacted_thinking|tool_use|tool_result]}),
14
+ // loaded in file order; a user message holding tool_result blocks is a tool message. A message's
15
+ // `visibility` is "user_only" for notices the model never sees and "llm_only" for context the person
16
+ // never sees. "compaction_state" events carry a summary and an anchor: the model's context is the summary
17
+ // plus the messages after the anchor. Other event types are bookkeeping. A <uuid>.settings.json beside the
18
+ // file holds the model and token totals; Droid creates it when it first opens a session without one.
19
+
20
+ /**
21
+ * The person's own words in a user message: Droid's reminders (and the ones Claude Code attaches to a message) come
22
+ * as `<system-reminder>` blocks, so those are taken off the front and what is left is theirs. A message that is only
23
+ * reminders has nothing of the person's in it. A session whose first words follow a reminder is not mistaken for one.
24
+ */
25
+ export function personText(text: string): string {
26
+ return text.replace(/^(?:\s*<system-reminder>[\s\S]*?<\/system-reminder>)+/, "").trim()
27
+ }
28
+
29
+ export type Event = Record<string, any>
30
+
31
+ export const sessionsRoot = (home: string) => join(home, ".factory", "sessions")
32
+
33
+ /** The project key Droid files a folder's sessions under: "-" and the resolved path, its slashes turned into "-". */
34
+ export function projectKey(cwd: string): string {
35
+ let p = resolve(cwd)
36
+ try {
37
+ p = realpathSync(p)
38
+ } catch {
39
+ // a folder that does not exist yet: Droid uses the path as given
40
+ }
41
+ return `-${p.replace(/[\\/]+$/, "").replace(/^\/+/, "").replace(/\/+/g, "-")}`
42
+ }
43
+
44
+ /** Every event of a session file; none when there is no session_start line (Droid's own check). */
45
+ export function parseEvents(text: string): Event[] {
46
+ const out: Event[] = []
47
+ for (const line of text.split("\n")) {
48
+ if (!line.trim()) continue
49
+ try {
50
+ out.push(JSON.parse(line))
51
+ } catch {
52
+ // a half-written line: Droid skips it too
53
+ }
54
+ }
55
+ return out.some((e) => e?.type === "session_start" && typeof e.id === "string") ? out : []
56
+ }
57
+
58
+ /** Droid's built-in tools, by the name the model calls them. */
59
+ const DROID_KINDS: Record<string, ToolKind> = {
60
+ Read: "read",
61
+ Edit: "edit",
62
+ MultiEdit: "edit",
63
+ ApplyPatch: "edit",
64
+ Create: "write",
65
+ Execute: "shell",
66
+ LS: "list",
67
+ Grep: "search",
68
+ Glob: "search",
69
+ WebSearch: "web",
70
+ FetchUrl: "web",
71
+ Task: "task",
72
+ TodoWrite: "todo",
73
+ AskUser: "ask",
74
+ }
75
+
76
+ export const kindOf = (name: string): ToolKind => DROID_KINDS[name] ?? toolKindOf(name)
77
+
78
+ /** JSON for one line, with the two line separators escaped as Droid writes them. */
79
+ export const line = (v: unknown) => JSON.stringify(v).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
@@ -0,0 +1,350 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { userInfo } from "node:os"
4
+ import { dirname, extname, join } from "node:path"
5
+ import { toolLabel } from "../../brief.ts"
6
+ import { exists, isDir, versionOf } from "../../fsutil.ts"
7
+ import type { Session, Turn } from "../../session/types.ts"
8
+ import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
9
+ import { continuedNote } from "../claude-code/records.ts"
10
+ import { commandOf } from "../pi/writer.ts"
11
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
12
+ import { parseSession } from "./reader.ts"
13
+ import { type Event, line, personText, projectKey, sessionsRoot } from "./records.ts"
14
+
15
+ // Continues a portable session as a new Factory Droid session: one file, <sessions>/<project key>/<uuid>.jsonl,
16
+ // with the session_start line and message events Droid writes itself (its own importedHistory path writes
17
+ // the same lines). `droid --resume <id>` loads it, its session picker and `droid search` list it, and Droid
18
+ // adds the .settings.json companion with the person's default model when it first opens the session.
19
+ //
20
+ // Messages are Anthropic-style, as Droid stores them: each response is one assistant message with its text
21
+ // and tool_use blocks, and the results of its calls follow in one user message of tool_result blocks.
22
+ // Calls from other tools become Droid's own Read / Create / Edit / Execute / Grep / Glob / LS when their
23
+ // arguments map; others keep their names. The note saying where the session came from is a user message
24
+ // with visibility "llm_only": the model reads it, the person never sees it. A compaction is a
25
+ // compaction_state whose anchor is the last message before it, so Droid continues from its summary while
26
+ // the earlier turns stay on screen; the note goes right after the last one, where the model's context starts.
27
+ // Readable reasoning is dropped: Droid's thinking blocks are sent back to the model and need the
28
+ // provider's signature.
29
+
30
+ /** droid versions whose session format this writer was checked against. */
31
+ export const DROID_TESTED_VERSIONS = ["1.6.0"]
32
+
33
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
34
+ const IMAGE_LIMIT = 5 * 1024 * 1024
35
+
36
+ type Call = Extract<Turn, { kind: "tool_call" }>
37
+
38
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
39
+
40
+ const str = (o: Event, ...keys: string[]): string | undefined => {
41
+ for (const k of keys) if (typeof o[k] === "string" && o[k]) return o[k] as string
42
+ return undefined
43
+ }
44
+ const num = (o: Event, key: string, as: string) => (typeof o[key] === "number" ? { [as]: o[key] } : {})
45
+
46
+ /** A call from another tool as one of Droid's built-in tools, when its arguments say enough; undefined keeps it as it was. */
47
+ export function asDroidTool(call: Call): { name: string; input: Event } | undefined {
48
+ const i = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Event) : undefined
49
+ if (!i) return undefined
50
+ const path = str(i, "file_path", "path", "filePath", "absolute_path", "target_file")
51
+ switch (call.toolKind) {
52
+ case "read":
53
+ return path ? { name: "Read", input: { file_path: path, ...num(i, "offset", "offset"), ...num(i, "limit", "limit") } } : undefined
54
+ case "write": {
55
+ const content = i.content ?? i.contents ?? i.file_text
56
+ return path && typeof content === "string" ? { name: "Create", input: { file_path: path, content } } : undefined
57
+ }
58
+ case "edit": {
59
+ // One replacement only: Droid's Edit has no list of edits.
60
+ const one = Array.isArray(i.edits) ? (i.edits.length === 1 ? (i.edits[0] as Event) : undefined) : i
61
+ const oldStr = one?.old_string ?? one?.old_str ?? one?.oldText
62
+ const newStr = one?.new_string ?? one?.new_str ?? one?.newText
63
+ if (!path || typeof oldStr !== "string" || typeof newStr !== "string") return undefined
64
+ const all = i.replace_all === true || i.change_all === true
65
+ return { name: "Edit", input: { file_path: path, old_str: oldStr, new_str: newStr, ...(all ? { change_all: true } : {}) } }
66
+ }
67
+ case "shell": {
68
+ const command = commandOf(i)
69
+ return command ? { name: "Execute", input: { command } } : undefined
70
+ }
71
+ case "search": {
72
+ const where = str(i, "path", "folder", "dir_path", "directory")
73
+ if (/glob|find|file_search/i.test(call.name)) {
74
+ const patterns = i.patterns ?? i.pattern
75
+ return typeof patterns === "string" || (Array.isArray(patterns) && patterns.every((p) => typeof p === "string"))
76
+ ? { name: "Glob", input: { patterns, ...(where ? { folder: where } : {}) } }
77
+ : undefined
78
+ }
79
+ const pattern = str(i, "pattern", "regex", "query")
80
+ const glob = str(i, "glob_pattern", "glob", "include")
81
+ return pattern ? { name: "Grep", input: { pattern, ...(where ? { path: where } : {}), ...(glob ? { glob_pattern: glob } : {}) } } : undefined
82
+ }
83
+ case "list": {
84
+ const where = str(i, "directory_path", "path", "dir_path", "target_directory", "directory", "folder")
85
+ return { name: "LS", input: where ? { directory_path: where } : {} }
86
+ }
87
+ default:
88
+ return undefined
89
+ }
90
+ }
91
+
92
+ export interface DroidPlan {
93
+ /** The session_start line and every event, in order. */
94
+ lines: Event[]
95
+ lost: string[]
96
+ /** What a read-back must find. */
97
+ expect: { users: number; assistants: number; calls: number }
98
+ }
99
+
100
+ /** The lines of a new Droid session `id` continuing `session`. Pure apart from reading attached images. */
101
+ export function planDroidSession(session: Session, opts: { id: string; now: Date; cwd: string; owner: string }): DroidPlan {
102
+ const drops = new Map<string, number>()
103
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
104
+ const label = toolLabel(session.header.tool)
105
+ const expect = { users: 0, assistants: 0, calls: 0 }
106
+ const title = session.header.title ?? firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60) ?? "Session"
107
+ // A title set by hand keeps Droid from asking a model to name the session.
108
+ const lines: Event[] = [{ type: "session_start", id: opts.id, title: `${title} (from ${label})`, isSessionTitleManuallySet: true, owner: opts.owner, version: 2, cwd: opts.cwd }]
109
+
110
+ // Timestamps never go backwards; turns without one take the previous time.
111
+ const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
112
+ let clock = (known ?? opts.now.getTime()) - 1
113
+ const stamp = (at?: string) => {
114
+ const t = at ? Date.parse(at) : Number.NaN
115
+ clock = Number.isFinite(t) && t > clock ? t : clock + 1
116
+ return new Date(clock).toISOString()
117
+ }
118
+ let parent: string | undefined
119
+ let count = 0
120
+ const message = (m: Event, at?: string) => {
121
+ const id = randomUUID()
122
+ lines.push({ type: "message", id, timestamp: stamp(at), ...(parent ? { parentId: parent } : {}), message: m })
123
+ parent = id
124
+ count++
125
+ }
126
+
127
+ const note = () => message({ role: "user", content: [{ type: "text", text: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)) }], visibility: "llm_only" })
128
+ const lastCompaction = session.turns.map((t) => t.kind).lastIndexOf("compaction")
129
+ if (lastCompaction < 0) note()
130
+
131
+ // Call ids as the Anthropic format allows them.
132
+ const ids = new Map<string, string>()
133
+ const taken = new Set<string>()
134
+ let remapped = 0
135
+ let renamed = 0
136
+ const callId = (id: string) => {
137
+ let v = ids.get(id)
138
+ if (v) return v
139
+ v = /^[A-Za-z0-9_-]{1,64}$/.test(id) && !taken.has(id) ? id : `toolu_${randomUUID().replace(/-/g, "").slice(0, 24)}`
140
+ if (v !== id) remapped++
141
+ ids.set(id, v)
142
+ taken.add(v)
143
+ return v
144
+ }
145
+
146
+ const written = new Set<string>()
147
+ const done = new Set<string>()
148
+ let cur: { at?: string; blocks: Event[]; calls: string[] } | undefined
149
+ // The results of the last response's calls, until something else comes.
150
+ let res: { at?: string; blocks: Event[]; calls: string[] } | undefined
151
+ const closeResults = () => {
152
+ if (!res) return
153
+ const r = res
154
+ res = undefined
155
+ // Every call is answered before the next request; a call that never got a result is closed as an error.
156
+ for (const id of r.calls) {
157
+ if (done.has(id)) continue
158
+ drop("tool calls without a result (closed as errors)")
159
+ r.blocks.push({ type: "tool_result", tool_use_id: callId(id), content: "No result was recorded for this call; the session moved on before it finished.", is_error: true })
160
+ done.add(id)
161
+ }
162
+ message({ role: "user", content: r.blocks }, r.at)
163
+ }
164
+ const flush = () => {
165
+ if (cur?.blocks.length) {
166
+ const c = cur
167
+ message({ role: "assistant", content: c.blocks }, c.at)
168
+ if (c.calls.length) res = { blocks: [], calls: c.calls }
169
+ }
170
+ cur = undefined
171
+ }
172
+ const settle = () => {
173
+ flush()
174
+ closeResults()
175
+ }
176
+ const assistant = (at?: string) => (cur ??= { ...(at ? { at } : {}), blocks: [], calls: [] })
177
+
178
+ for (const [i, t] of session.turns.entries()) {
179
+ switch (t.kind) {
180
+ case "user": {
181
+ settle()
182
+ const blocks: Event[] = []
183
+ let text = t.text
184
+ for (const a of t.attachments ?? []) {
185
+ if (!a.path) continue // bytes the reader could not keep; it already counted them
186
+ if (a.kind === "file") {
187
+ text += `\n\n[Attached file: ${a.path}]`
188
+ continue
189
+ }
190
+ const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
191
+ let size = Number.POSITIVE_INFINITY
192
+ try {
193
+ size = statSync(a.path).size
194
+ } catch {}
195
+ if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT)
196
+ blocks.push({ type: "image", source: { type: "base64", media_type: mime, data: readFileSync(a.path).toString("base64") } })
197
+ else drop("images that could not be attached")
198
+ }
199
+ if (!text.trim() && !blocks.length) break
200
+ message({ role: "user", content: [{ type: "text", text: text.trim() ? text : "(image)" }, ...blocks] }, t.at)
201
+ // A message that is only reminders reads back as none of the person's, as Droid's own reminders do.
202
+ if (personText(text) || blocks.length) expect.users++
203
+ break
204
+ }
205
+ case "assistant":
206
+ // Text after a tool call is a new response: it was written after the call's result came back.
207
+ if (cur?.calls.length) flush()
208
+ closeResults()
209
+ if (!t.text.trim()) break
210
+ assistant(t.at).blocks.push({ type: "text", text: t.text })
211
+ expect.assistants++
212
+ break
213
+ case "reasoning":
214
+ if (t.text.trim()) drop("reasoning (Droid sends thinking back to the model, which needs the provider's signature)")
215
+ break
216
+ case "tool_call": {
217
+ if (written.has(t.id)) {
218
+ drop("repeated tool call ids")
219
+ break
220
+ }
221
+ closeResults()
222
+ const d = asDroidTool(t)
223
+ // Unmapped names must still suit the Anthropic tool-name rule.
224
+ const name = d?.name ?? (t.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool")
225
+ if (name !== t.name) renamed++
226
+ const input = d?.input ?? (t.input && typeof t.input === "object" && !Array.isArray(t.input) ? (t.input as Event) : { input: t.input ?? null })
227
+ assistant(t.at).blocks.push({ type: "tool_use", id: callId(t.id), name, input })
228
+ cur!.calls.push(t.id)
229
+ written.add(t.id)
230
+ expect.calls++
231
+ break
232
+ }
233
+ case "tool_result": {
234
+ if (!written.has(t.callId)) {
235
+ drop("tool results without a call")
236
+ break
237
+ }
238
+ if (done.has(t.callId)) {
239
+ drop("repeated or late tool results")
240
+ break
241
+ }
242
+ // A result closes the response that made the call.
243
+ if (cur?.calls.includes(t.callId)) flush()
244
+ if (!res?.calls.includes(t.callId)) {
245
+ drop("repeated or late tool results")
246
+ break
247
+ }
248
+ res.at ??= t.at
249
+ const output = t.fullPath ? `${t.output}\n\n[Full output: ${t.fullPath}]` : t.output
250
+ res.blocks.push({ type: "tool_result", tool_use_id: callId(t.callId), content: output.trim() ? output : "(no output)", ...(t.ok ? {} : { is_error: true }) })
251
+ done.add(t.callId)
252
+ break
253
+ }
254
+ case "compaction": {
255
+ settle()
256
+ // The anchor is the last message the summary covers; with none it summarises what came before this session.
257
+ const anchor = parent ? { anchorMessage: { id: parent, index: count - 1 } } : {}
258
+ lines.push({ type: "compaction_state", id: randomUUID(), timestamp: stamp(t.at), summaryText: t.summary, summaryTokens: Math.round(t.summary.length / 4), summaryKind: "llm_summary", ...anchor, removedCount: count })
259
+ if (i === lastCompaction) note()
260
+ break
261
+ }
262
+ case "file_edit":
263
+ if (!t.callId || !written.has(t.callId)) drop("file edits outside a tool call")
264
+ break
265
+ case "subagent":
266
+ drop("subagent transcripts (their results stay in the parent's tool results)")
267
+ break
268
+ }
269
+ }
270
+ settle()
271
+ if (!expect.users && !expect.assistants && !expect.calls) throw new Error("nothing to continue: the session has no messages or tool calls")
272
+
273
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
274
+ if (renamed) lost.push(`original tool names (${renamed} calls renamed to Droid's tools or to fit its rules)`)
275
+ if (remapped) lost.push(`tool call ids remapped (${remapped})`)
276
+ return { lines, lost, expect }
277
+ }
278
+
279
+ /** droid's version from `droid --version` ("1.6.0"), or undefined when it is missing. */
280
+ export function droidVersion(): string | undefined {
281
+ return versionOf("droid")?.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)?.[1]
282
+ }
283
+
284
+ /** Droid records the folder as the process saw it: the resolved path (/private/tmp, not /tmp). */
285
+ function realCwd(cwd: string): string {
286
+ try {
287
+ return realpathSync(cwd)
288
+ } catch {
289
+ return cwd
290
+ }
291
+ }
292
+
293
+ function currentUser(): string {
294
+ try {
295
+ return userInfo().username
296
+ } catch {
297
+ return "unknown"
298
+ }
299
+ }
300
+
301
+ /** `version` and `owner` are for tests, which must not depend on the installed binary or the account. */
302
+ export function factoryDroidWriter(o: { version?: string; owner?: string } = {}): Writer {
303
+ const installed = () => o.version ?? droidVersion()
304
+ return {
305
+ tool: "factory-droid",
306
+ label: "Factory Droid",
307
+ way: "native",
308
+
309
+ async ready(w: WriteOptions): Promise<Readiness> {
310
+ const v = installed()
311
+ if (!v) return { ok: false, why: "Factory Droid is not installed here (`droid --version` failed)" }
312
+ if (!DROID_TESTED_VERSIONS.includes(v)) return { ok: false, why: `Factory Droid ${v} is not a version this writer was checked against (${DROID_TESTED_VERSIONS.join(", ")})` }
313
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Droid keeps sessions by the folder they belong to` }
314
+ return { ok: true }
315
+ },
316
+
317
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
318
+ const cwd = realCwd(w.cwd)
319
+ const dir = join(sessionsRoot(w.home), projectKey(cwd))
320
+ let id = randomUUID()
321
+ while (exists(join(dir, `${id}.jsonl`))) id = randomUUID()
322
+ const path = join(dir, `${id}.jsonl`)
323
+ const plan = planDroidSession(session, { id, now: new Date(), cwd, owner: o.owner ?? currentUser() })
324
+ if (!w.dryRun) {
325
+ // Droid keeps its session folders and files private to the account.
326
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
327
+ // "wx": a new file only; an existing session is never touched.
328
+ writeFileSync(path, `${plan.lines.map(line).join("\n")}\n`, { flag: "wx", mode: 0o600 })
329
+ const back = parseSession(readFileSync(path, "utf8"), path, w.home)
330
+ const n = countTurns(back)
331
+ const pairs = unpaired(back)
332
+ if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
333
+ rmSync(path, { force: true })
334
+ throw new Error(
335
+ `the Droid 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`,
336
+ )
337
+ }
338
+ }
339
+ return {
340
+ tool: "factory-droid",
341
+ way: "native",
342
+ sessionId: id,
343
+ written: [path],
344
+ open: `cd ${shellQuote(cwd)} && droid --resume ${id}`,
345
+ lost: plan.lost,
346
+ dryRun: !!w.dryRun,
347
+ }
348
+ },
349
+ }
350
+ }