brane 0.0.2 → 0.3.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 (338) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +81 -23
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.npmignore +0 -2
  336. package/.travis.yml +0 -5
  337. package/index.js +0 -8
  338. package/index.js~ +0 -12
@@ -0,0 +1,892 @@
1
+ /**
2
+ * `brane absorb claude` — the Claude Code session store as an absorb source.
3
+ *
4
+ * Fixtures are written by the test into a temp dir rather than read from the
5
+ * checkout, for two reasons: real sessions hold private content, and `brane
6
+ * doctor` runs this suite from the tarball, where no fixture file would be.
7
+ * Each fixture is the smallest JSONL that reproduces one thing the parser or
8
+ * the inserter has to get right.
9
+ */
10
+ import { describe, it } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, utimesSync, appendFileSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { createTestDb } from "./helpers.js";
16
+ import { parseClaudeSession, surveyClaudeSessions, claudeIdentity, claudeCodeSource, classifyUserBlock, } from "../absorb/claude-code.js";
17
+ import { createAbsorbContext, absorbOne } from "../absorb/index.js";
18
+ import { classifySessions, surveyAbsorbSources } from "../absorb/sources.js";
19
+ import { deriveBraneId } from "../absorb/brane-id.js";
20
+ import { runAbsorb, selectSessions, parseSince, resetAbsorbedNarrative, liveContributions, AbsorbInputError } from "../absorb/run.js";
21
+ import { formatSurvey, formatSummary } from "../commands/absorb.js";
22
+ import { SCHEMA_SQL, applyMigrations } from "../db/schema.js";
23
+ import Database from "better-sqlite3";
24
+ const T0 = Date.parse("2026-09-01T10:00:00.000Z");
25
+ const iso = (offsetMs) => new Date(T0 + offsetMs).toISOString();
26
+ function user(uuid, parent, text, at, extra = {}) {
27
+ return {
28
+ type: "user", uuid, parentUuid: parent, isSidechain: false, timestamp: iso(at),
29
+ cwd: "/home/me/dev/app", sessionId: "S", version: "2.1.0", gitBranch: "main",
30
+ message: { role: "user", content: text }, ...extra,
31
+ };
32
+ }
33
+ function assistant(uuid, parent, content, at, extra = {}) {
34
+ return {
35
+ type: "assistant", uuid, parentUuid: parent, isSidechain: false, timestamp: iso(at),
36
+ cwd: "/home/me/dev/app", sessionId: "S", version: "2.1.0", gitBranch: "main", requestId: "req_" + uuid,
37
+ message: { model: "claude-opus-5", id: "msg_" + uuid, type: "message", role: "assistant", content, stop_reason: "end_turn", usage: { input_tokens: 1, output_tokens: 1 } },
38
+ ...extra,
39
+ };
40
+ }
41
+ function toolResult(uuid, parent, toolUseId, output, at, isError = false) {
42
+ return {
43
+ type: "user", uuid, parentUuid: parent, isSidechain: false, timestamp: iso(at),
44
+ cwd: "/home/me/dev/app", sessionId: "S", version: "2.1.0",
45
+ message: { role: "user", content: [{ type: "tool_result", tool_use_id: toolUseId, content: output, ...(isError ? { is_error: true } : {}) }] },
46
+ toolUseResult: output,
47
+ };
48
+ }
49
+ const jsonl = (recs) => recs.map((r) => JSON.stringify(r)).join("\n") + "\n";
50
+ /** A message sent while a turn was running, as the harness records its
51
+ * delivery: an `attachment` written where the agent read it, stamped with
52
+ * the time it was typed (`at`). Real records from 2.1.23 on carry a
53
+ * `source_uuid`; task notifications carry no `origin`. */
54
+ function queued(uuid, parent, prompt, at, commandMode = "prompt", extra = {}) {
55
+ return {
56
+ type: "attachment", uuid, parentUuid: parent, isSidechain: false, timestamp: iso(at),
57
+ cwd: "/home/me/dev/app", sessionId: "S", version: "2.1.24",
58
+ attachment: {
59
+ type: "queued_command", prompt, commandMode, timestamp: iso(at),
60
+ ...(commandMode === "prompt" ? { origin: { kind: "human" }, source_uuid: "src-" + uuid } : {}),
61
+ },
62
+ ...extra,
63
+ };
64
+ }
65
+ function queueOp(operation, at, content, reason) {
66
+ return { type: "queue-operation", operation, timestamp: iso(at), sessionId: "S", ...(content ? { content } : {}), ...(reason ? { reason } : {}) };
67
+ }
68
+ /** A session in which the operator spoke mid-turn: the question "also check
69
+ * the lockfile" was typed at 1500 while a build ran, and read by the agent
70
+ * when the build's result came back at 3000. A second message was queued
71
+ * and then popped back into the editor — cancelled, never delivered. */
72
+ function midTurnSession() {
73
+ return [
74
+ user("u1", null, "Why does the build fail?", 0),
75
+ assistant("a1", "u1", [
76
+ { type: "text", text: "Let me look." },
77
+ { type: "tool_use", id: "toolu_01", name: "Bash", input: { command: "npm run build" } },
78
+ ], 1000),
79
+ queueOp("enqueue", 1500, "also check the lockfile"),
80
+ toolResult("r1", "a1", "toolu_01", "error TS2322 in foo.ts", 3000),
81
+ queued("q1", "r1", "also check the lockfile", 1500),
82
+ queueOp("remove", 3001, "also check the lockfile", "absorbed_mid_turn"),
83
+ assistant("a2", "q1", [{ type: "text", text: "A type error in foo.ts, and the lockfile is fine." }], 5000),
84
+ queueOp("enqueue", 5500, "never mind"),
85
+ queueOp("popAll", 5600),
86
+ ];
87
+ }
88
+ /** A 1×1 PNG, as the harness writes a pasted image: base64 in the record. */
89
+ const PNG_1PX = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
90
+ function image(mediaType = "image/png", data = PNG_1PX) {
91
+ return { type: "image", source: { type: "base64", media_type: mediaType, data } };
92
+ }
93
+ /** A plain session: prompt, text + tool call, result, answer, retitled. */
94
+ function plainSession() {
95
+ return [
96
+ { type: "mode", mode: "default", sessionId: "S" },
97
+ { type: "permission-mode", permissionMode: "default", sessionId: "S" },
98
+ user("u1", null, "Why does the build fail?", 0),
99
+ { type: "ai-title", aiTitle: "Build failure", sessionId: "S" },
100
+ { type: "attachment", uuid: "att1", parentUuid: "u1", isSidechain: false, timestamp: iso(500), attachment: { type: "deferred_tools_delta" }, cwd: "/home/me/dev/app", sessionId: "S" },
101
+ assistant("a1", "u1", [
102
+ { type: "thinking", thinking: "private scratch", signature: "x" },
103
+ { type: "text", text: "Let me look." },
104
+ { type: "tool_use", id: "toolu_01", name: "Bash", input: { command: "npm run build" } },
105
+ ], 1000),
106
+ toolResult("r1", "a1", "toolu_01", "error TS2322 in foo.ts", 4000),
107
+ assistant("a2", "r1", [{ type: "text", text: "A type error in foo.ts." }], 5000),
108
+ { type: "ai-title", aiTitle: "Build failure: TS2322 in foo.ts", sessionId: "S" },
109
+ { type: "last-prompt", lastPrompt: "Why does the build fail?", leafUuid: "a2", sessionId: "S" },
110
+ ];
111
+ }
112
+ function candidateFor(path, id = "S") {
113
+ return { externalId: id, path, cwd: "/home/me/dev/app", startedAt: T0, updatedAt: T0 + 5000, bytes: 1 };
114
+ }
115
+ function tempStore() {
116
+ return mkdtempSync(join(tmpdir(), "brane-claude-store-"));
117
+ }
118
+ /** Write a session into `<root>/<project>/<id>.jsonl` with a fixed mtime. */
119
+ function writeSession(root, project, id, recs, mtimeMs = T0 + 5000) {
120
+ const dir = join(root, project);
121
+ mkdirSync(dir, { recursive: true });
122
+ const path = join(dir, `${id}.jsonl`);
123
+ writeFileSync(path, jsonl(recs));
124
+ utimesSync(path, mtimeMs / 1000, mtimeMs / 1000);
125
+ return path;
126
+ }
127
+ // ---- the parser -----------------------------------------------------------
128
+ describe("parseClaudeSession", () => {
129
+ it("keeps the conversation, drops the bookkeeping, and takes the newest title", () => {
130
+ const conv = parseClaudeSession(jsonl(plainSession()), candidateFor("/x/S.jsonl"));
131
+ assert.equal(conv.externalId, "S");
132
+ assert.equal(conv.title, "Build failure: TS2322 in foo.ts");
133
+ assert.equal(conv.cwd, "/home/me/dev/app");
134
+ assert.equal(conv.model, "claude-opus-5");
135
+ assert.deepEqual(conv.messages.map((m) => [m.role, m.content, m.key]), [
136
+ ["user", "Why does the build fail?", "u1"],
137
+ ["assistant", "Let me look.", "a1"],
138
+ ["assistant", "A type error in foo.ts.", "a2"],
139
+ ]);
140
+ assert.equal(conv.createdAt, T0);
141
+ assert.equal(conv.updatedAt, T0 + 5000);
142
+ // thinking blocks are not voice
143
+ assert.ok(!conv.messages.some((m) => m.content.includes("private scratch")));
144
+ });
145
+ it("pairs a tool_use with its tool_result: output, status, duration, anchor", () => {
146
+ const conv = parseClaudeSession(jsonl(plainSession()), candidateFor("/x/S.jsonl"));
147
+ assert.equal(conv.toolCalls.length, 1);
148
+ const call = conv.toolCalls[0];
149
+ assert.equal(call.key, "toolu_01");
150
+ assert.equal(call.name, "Bash");
151
+ assert.deepEqual(call.input, { command: "npm run build" });
152
+ assert.equal(call.output, "error TS2322 in foo.ts");
153
+ assert.equal(call.status, "completed");
154
+ assert.equal(call.startedAt, T0 + 1000);
155
+ assert.equal(call.durationMs, 3000);
156
+ assert.equal(call.afterMessage, 1); // after "Let me look."
157
+ });
158
+ it("marks an is_error result failed and an unanswered call unknown", () => {
159
+ const recs = [
160
+ user("u1", null, "go", 0),
161
+ assistant("a1", "u1", [
162
+ { type: "tool_use", id: "toolu_bad", name: "Bash", input: { command: "false" } },
163
+ { type: "tool_use", id: "toolu_open", name: "Read", input: { file_path: "/x" } },
164
+ ], 1000),
165
+ toolResult("r1", "a1", "toolu_bad", "exit 1", 2000, true),
166
+ ];
167
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
168
+ const byKey = Object.fromEntries(conv.toolCalls.map((c) => [c.key, c.status]));
169
+ assert.deepEqual(byKey, { toolu_bad: "failed", toolu_open: "unknown" });
170
+ // a call before any message anchors at -1
171
+ assert.equal(conv.toolCalls[0].afterMessage, 0); // after the user prompt
172
+ });
173
+ it("ignores injected user text, sidechain turns, and tool-result-only records as messages", () => {
174
+ const recs = [
175
+ user("u0", null, "<system-reminder>injected</system-reminder>", 0),
176
+ user("u1", "u0", "real prompt", 100),
177
+ user("m1", "u1", "harness note", 200, { isMeta: true }),
178
+ user("s1", "u1", "subagent prompt", 300, { isSidechain: true }),
179
+ assistant("s2", "s1", [{ type: "text", text: "subagent answer" }], 400, { isSidechain: true }),
180
+ assistant("a1", "u1", [{ type: "text", text: "answer" }], 500),
181
+ ];
182
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
183
+ assert.deepEqual(conv.messages.map((m) => m.content), ["real prompt", "answer"]);
184
+ });
185
+ it("keeps a pasted image as an attachment, and a companion image the isMeta rule would otherwise drop", () => {
186
+ const recs = [
187
+ user("u1", null, "hello", 0),
188
+ user("u2", "u1", "", 100, { message: { role: "user", content: [{ type: "text", text: "[Image #1] what is this?" }, image()] }, imagePasteIds: [1] }),
189
+ // In the store a companion's parent is a hook `attachment` record the parser never sees.
190
+ user("m1", "att-hook-1", "", 200, { isMeta: true, turnCompanion: true, message: { role: "user", content: [image("image/jpeg", "/9j/4AAQ")] } }),
191
+ user("m2", "m1", "harness note", 300, { isMeta: true }),
192
+ assistant("a1", "m1", [{ type: "text", text: "a picture" }], 400),
193
+ ];
194
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
195
+ assert.deepEqual(conv.messages.map((m) => [m.content, m.attachments?.map((a) => a.mediaType) ?? null]), [
196
+ ["hello", null],
197
+ ["[Image #1] what is this?", ["image/png"]],
198
+ ["(attachment)", ["image/jpeg"]], // image with no words: the web app's own placeholder
199
+ ["a picture", null],
200
+ ]);
201
+ assert.equal(conv.messages[1].attachments[0].data, PNG_1PX);
202
+ assert.deepEqual(conv.messages.map((m) => m.re ?? null), [null, null, null, null]); // an unseen parent threads nothing
203
+ });
204
+ it("threads a branch point with re and leaves a straight line unadorned", () => {
205
+ const recs = [
206
+ user("u1", null, "one", 0),
207
+ assistant("a1", "u1", [{ type: "text", text: "two" }], 1000),
208
+ user("u2", "u1", "three, branching from one", 2000), // parent is u1, not a1
209
+ assistant("a2", "u2", [{ type: "text", text: "four" }], 3000),
210
+ ];
211
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
212
+ assert.deepEqual(conv.messages.map((m) => m.re ?? null), [null, null, [1], null]);
213
+ });
214
+ it("renders a compaction summary as a system message and falls back to the first prompt for a title", () => {
215
+ const recs = [
216
+ user("u1", null, "first line of the prompt\nsecond line", 0),
217
+ user("c1", "u1", "Summary of everything so far", 1000, { isCompactSummary: true }),
218
+ ];
219
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
220
+ assert.equal(conv.title, "first line of the prompt");
221
+ assert.deepEqual(conv.messages.map((m) => m.role), ["user", "system"]);
222
+ });
223
+ it("returns null for a file with no conversation and survives a torn last line", () => {
224
+ assert.equal(parseClaudeSession(jsonl([{ type: "mode", mode: "default", sessionId: "S" }]), candidateFor("/x/S.jsonl")), null);
225
+ const torn = jsonl([user("u1", null, "hi", 0)]) + '{"type":"assistant","uuid":"a1","mess';
226
+ const conv = parseClaudeSession(torn, candidateFor("/x/S.jsonl"));
227
+ assert.equal(conv.messages.length, 1);
228
+ });
229
+ it("reads a message sent mid-turn from its delivery receipt, where the agent read it; a cancelled one stays out", () => {
230
+ const conv = parseClaudeSession(jsonl(midTurnSession()), candidateFor("/x/S.jsonl"));
231
+ assert.deepEqual(conv.messages.map((m) => [m.role, m.content, m.key, m.time - T0]), [
232
+ ["user", "Why does the build fail?", "u1", 0],
233
+ ["assistant", "Let me look.", "a1", 1000],
234
+ ["user", "also check the lockfile", "q1", 1500], // after the result it waited on, at the time it was typed
235
+ ["assistant", "A type error in foo.ts, and the lockfile is fine.", "a2", 5000],
236
+ ]);
237
+ assert.ok(!conv.messages.some((m) => m.content.includes("never mind")), "queued then cancelled: no receipt, no message");
238
+ assert.equal(conv.toolCalls[0].afterMessage, 1);
239
+ });
240
+ it("routes a queued task notification to a tool row, and leaves unknown modes and subagent queues alone", () => {
241
+ const note = "<task-notification>\n<task-id>bg1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command finished</summary>\n<result>ok</result>\n</task-notification>";
242
+ const recs = [
243
+ user("u1", null, "run it in the background", 0),
244
+ assistant("a1", "u1", [{ type: "tool_use", id: "toolu_bg", name: "Bash", input: { command: "sleep 1", run_in_background: true } }], 100),
245
+ toolResult("r1", "a1", "toolu_bg", "running", 200),
246
+ queued("q1", "r1", note, 300, "task-notification"),
247
+ queued("q2", "q1", "not a mode this parser knows", 400, "some-future-mode"),
248
+ queued("q3", "q2", "a subagent's queue", 450, "prompt", { isSidechain: true }),
249
+ queued("q4", "q2", [{ type: "text", text: "[Image #1] and this" }, image()], 500),
250
+ { type: "attachment", uuid: "h1", parentUuid: "q4", isSidechain: false, timestamp: iso(550), attachment: { type: "hook_non_blocking_error" } },
251
+ assistant("a2", "q4", [{ type: "text", text: "done" }], 600),
252
+ ];
253
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
254
+ assert.deepEqual(conv.messages.map((m) => [m.content, m.attachments?.length ?? 0]), [
255
+ ["run it in the background", 0],
256
+ ["[Image #1] and this", 1],
257
+ ["done", 0],
258
+ ]);
259
+ const note1 = conv.toolCalls.find((c) => c.name === "task-notification");
260
+ assert.deepEqual([note1.key, note1.parentKey, note1.status, note1.afterMessage], ["task:q1", "toolu_bg", "completed", 0]);
261
+ });
262
+ it("titles a session by its first words, not by the slash command that opened it", () => {
263
+ const recs = [
264
+ user("u0", null, "<command-name>/clear</command-name><command-message>clear</command-message><command-args></command-args>", 0),
265
+ user("u1", "u0", "Why does the build fail?", 100),
266
+ ];
267
+ assert.equal(parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl")).title, "Why does the build fail?");
268
+ // Nothing but commands: the command is still better than no title.
269
+ assert.equal(parseClaudeSession(jsonl(recs.slice(0, 1)), candidateFor("/x/S.jsonl")).title, "`/clear`");
270
+ });
271
+ });
272
+ // ---- the survey -----------------------------------------------------------
273
+ describe("surveyClaudeSessions", () => {
274
+ it("lists every session file with cwd and start from the head, skipping agent-* transcripts", () => {
275
+ const root = tempStore();
276
+ writeSession(root, "-home-me-dev-app", "aaaa", plainSession());
277
+ writeSession(root, "-home-me-dev-app", "agent-zzzz", plainSession()); // subagent, not a session
278
+ writeSession(root, "-home-me-dev-other", "bbbb", [
279
+ { type: "mode", mode: "default", sessionId: "bbbb" },
280
+ user("u1", null, "hello", 0, { cwd: "/home/me/dev/other" }),
281
+ ]);
282
+ const found = surveyClaudeSessions(root).sort((a, b) => a.externalId.localeCompare(b.externalId));
283
+ assert.deepEqual(found.map((c) => [c.externalId, c.cwd, c.startedAt]), [
284
+ ["aaaa", "/home/me/dev/app", T0],
285
+ ["bbbb", "/home/me/dev/other", T0],
286
+ ]);
287
+ assert.ok(found.every((c) => c.bytes > 0 && c.updatedAt > 0));
288
+ });
289
+ it("locates nothing on a home without the store, and reads the account from ~/.claude.json", () => {
290
+ const home = tempStore();
291
+ assert.equal(claudeCodeSource.locate(home), null);
292
+ assert.equal(claudeIdentity(home).id.length > 0, true); // OS user fallback
293
+ mkdirSync(join(home, ".claude", "projects"), { recursive: true });
294
+ writeFileSync(join(home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "acct-1", emailAddress: "me@example.com" } }));
295
+ assert.equal(claudeCodeSource.locate(home), join(home, ".claude", "projects"));
296
+ assert.deepEqual(claudeIdentity(home), { id: "acct-1", email: "me@example.com", label: "me@example.com" });
297
+ });
298
+ });
299
+ describe("selectSessions / parseSince", () => {
300
+ const cands = [
301
+ { externalId: "abc-1", path: "/p/abc-1.jsonl", cwd: "/home/me/dev/app", startedAt: T0, updatedAt: T0 + 1000, bytes: 1 },
302
+ { externalId: "abd-2", path: "/p/abd-2.jsonl", cwd: "/home/me/dev/other", startedAt: T0, updatedAt: T0 + 100_000, bytes: 1 },
303
+ ];
304
+ it("filters by fold (cwd), since (mtime), and session (id or prefix)", () => {
305
+ assert.deepEqual(selectSessions(cands, { fold: "/home/me/dev/app" }).map((c) => c.externalId), ["abc-1"]);
306
+ assert.deepEqual(selectSessions(cands, { since: "1m" }, T0 + 120_000).map((c) => c.externalId), ["abd-2"]);
307
+ assert.deepEqual(selectSessions(cands, { session: "abd" }).map((c) => c.externalId), ["abd-2"]);
308
+ assert.deepEqual(selectSessions(cands, { session: "abc-1" }).map((c) => c.externalId), ["abc-1"]);
309
+ assert.throws(() => selectSessions(cands, { session: "ab" }), AbsorbInputError);
310
+ });
311
+ it("reads durations and dates", () => {
312
+ assert.equal(parseSince("2w", T0), T0 - 14 * 86_400_000);
313
+ assert.equal(parseSince("2026-09-01T00:00:00Z"), Date.parse("2026-09-01T00:00:00Z"));
314
+ assert.throws(() => parseSince("yesterday"), AbsorbInputError);
315
+ });
316
+ });
317
+ // ---- the inserter ---------------------------------------------------------
318
+ describe("absorbOne with keyed messages", () => {
319
+ it("writes messages as events, tool calls as tool_uses rows beside their message, and is idempotent", () => {
320
+ const db = createTestDb();
321
+ const conv = parseClaudeSession(jsonl(plainSession()), candidateFor("/x/S.jsonl"));
322
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", { id: "acct", label: "me@x" }, false);
323
+ assert.equal(absorbOne(ctx, conv), "inserted");
324
+ const nid = db.prepare("SELECT id, fold_path, role, agent_session_id FROM narratives").get();
325
+ assert.match(nid.id, /^claude-[0-9a-z]{8}$/);
326
+ assert.equal(nid.role, "absorbed");
327
+ assert.equal(nid.agent_session_id, "S");
328
+ assert.equal(nid.fold_path, "/home/me/dev/app"); // the session's own cwd, verbatim
329
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM folds").get().n, 0); // never registered
330
+ const events = db.prepare("SELECT seq, type, source, content FROM events WHERE narrative_id = ? ORDER BY seq").all(nid.id);
331
+ assert.deepEqual(events.map((e) => [e.seq, e.type, e.source]), [
332
+ [1, "human_signal", "claude:me@x"],
333
+ [2, "narration", "claude"],
334
+ [3, "narration", "claude"],
335
+ ]);
336
+ const rows = db.prepare("SELECT dedup_key, name, after_seq, status, output, duration_ms, source, agent_type FROM tool_uses").all();
337
+ assert.deepEqual(rows, [{
338
+ dedup_key: "toolu_01", name: "Bash", after_seq: 2, status: "completed",
339
+ output: "error TS2322 in foo.ts", duration_ms: 3000, source: "absorb", agent_type: "claude",
340
+ }]);
341
+ assert.equal(ctx.summary.toolCalls, 1);
342
+ // Same file again: nothing new, nothing duplicated, no rev bump.
343
+ const revBefore = db.prepare("SELECT rev FROM tool_uses").get().rev;
344
+ assert.equal(absorbOne(ctx, conv), "skipped");
345
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM events").get().n, 3);
346
+ assert.equal(db.prepare("SELECT rev FROM tool_uses").get().rev, revBefore);
347
+ assert.equal(ctx.summary.conversations, 1);
348
+ assert.equal(ctx.summary.skipped, 1);
349
+ db.close();
350
+ });
351
+ it("writes an inline attachment to payload storage and the payloads column, and only counts it on a dry run", () => {
352
+ const root = mkdtempSync(join(tmpdir(), "brane-payloads-"));
353
+ const payloadsDir = (nid) => {
354
+ const dir = join(root, nid);
355
+ mkdirSync(dir, { recursive: true });
356
+ return dir;
357
+ };
358
+ const recs = [
359
+ user("u1", null, "", 0, { message: { role: "user", content: [{ type: "text", text: "[Image #1]" }, image()] } }),
360
+ assistant("a1", "u1", [{ type: "text", text: "a dot" }], 1000),
361
+ ];
362
+ const db = createTestDb();
363
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, false, undefined, { payloadsDir });
364
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"))), "inserted");
365
+ const row = db.prepare("SELECT narrative_id, payloads FROM events WHERE seq = 1").get();
366
+ const paths = JSON.parse(row.payloads);
367
+ assert.deepEqual(paths, [`${row.narrative_id}/${T0}_1_0.png`]);
368
+ assert.deepEqual(readFileSync(join(root, paths[0])), Buffer.from(PNG_1PX, "base64"));
369
+ assert.equal(ctx.summary.assets, 1);
370
+ assert.equal(ctx.summary.assetsBytes, Buffer.from(PNG_1PX, "base64").length);
371
+ assert.equal(ctx.summary.unresolvedAssets, 0);
372
+ // The same file again is a skipped conversation: nothing rewritten, nothing recounted.
373
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"))), "skipped");
374
+ assert.equal(ctx.summary.assets, 1);
375
+ db.close();
376
+ const dry = createAbsorbContext(createTestDb(), "claude", "brane-claude-1", "~/store", undefined, true, undefined, {
377
+ payloadsDir: () => { throw new Error("a dry run must not touch payload storage"); },
378
+ });
379
+ assert.equal(absorbOne(dry, parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"))), "inserted");
380
+ assert.equal(dry.summary.assets, 1);
381
+ assert.equal(dry.summary.assetsBytes, Buffer.from(PNG_1PX, "base64").length);
382
+ });
383
+ it("a failed attachment write costs that one image, not the message or the import", () => {
384
+ const missing = join(mkdtempSync(join(tmpdir(), "brane-payloads-")), "never-created");
385
+ const db = createTestDb();
386
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, false, undefined, { payloadsDir: () => missing });
387
+ const recs = [
388
+ user("u1", null, "", 0, { message: { role: "user", content: [{ type: "text", text: "[Image #1]" }, image()] } }),
389
+ assistant("a1", "u1", [{ type: "text", text: "a dot" }], 1000),
390
+ ];
391
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"))), "inserted");
392
+ const rows = db.prepare("SELECT content, payloads FROM events ORDER BY seq").all();
393
+ assert.deepEqual(rows, [{ content: "[Image #1]", payloads: null }, { content: "a dot", payloads: null }]);
394
+ assert.equal(ctx.summary.assets, 0);
395
+ assert.equal(ctx.summary.unresolvedAssets, 1);
396
+ db.close();
397
+ });
398
+ it("appends only the messages a grown file added, with their tool calls", () => {
399
+ const db = createTestDb();
400
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, false);
401
+ const recs = plainSession();
402
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"))), "inserted");
403
+ recs.push(user("u2", "a2", "And the fix?", 6000), assistant("a3", "u2", [{ type: "tool_use", id: "toolu_02", name: "Edit", input: { file_path: "foo.ts" } }], 7000), toolResult("r2", "a3", "toolu_02", "ok", 7500), assistant("a4", "r2", [{ type: "text", text: "Cast it." }], 8000));
404
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"))), "appended");
405
+ const events = db.prepare("SELECT seq, content FROM events ORDER BY seq").all();
406
+ assert.deepEqual(events.map((e) => e.content), [
407
+ "Why does the build fail?", "Let me look.", "A type error in foo.ts.", "And the fix?", "Cast it.",
408
+ ]);
409
+ const calls = db.prepare("SELECT dedup_key, after_seq FROM tool_uses ORDER BY after_seq").all();
410
+ assert.deepEqual(calls, [{ dedup_key: "toolu_01", after_seq: 2 }, { dedup_key: "toolu_02", after_seq: 4 }]);
411
+ assert.equal(ctx.summary.appended, 1);
412
+ db.close();
413
+ });
414
+ it("appends a message an earlier parser skipped at the tail, re its place, and moves no seq", () => {
415
+ const db = createTestDb();
416
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, false);
417
+ // What an import that did not read queued turns wrote: everything but q1.
418
+ const full = midTurnSession();
419
+ const old = full.filter((r) => r.uuid !== "q1");
420
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(old), candidateFor("/x/S.jsonl"))), "inserted");
421
+ const nid = db.prepare("SELECT id FROM narratives").get().id;
422
+ const before = db.prepare("SELECT seq, content FROM events ORDER BY seq").all();
423
+ // The same file read again by this parser, grown by one more turn.
424
+ full.push(user("u3", "a2", "thanks", 6000));
425
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(full), candidateFor("/x/S.jsonl"))), "appended");
426
+ const events = db.prepare("SELECT seq, content, re FROM events ORDER BY seq").all();
427
+ assert.deepEqual(events.slice(0, 3).map((e) => ({ seq: e.seq, content: e.content })), before, "every seq anyone cited still says what it said");
428
+ assert.deepEqual(events.slice(3).map((e) => [e.seq, e.content, e.re]), [
429
+ [4, "also check the lockfile", JSON.stringify([2])], // late: re the message it follows
430
+ [5, "thanks", null], // new at the end: in place, unadorned
431
+ ]);
432
+ assert.deepEqual(db.prepare("SELECT key, seq FROM absorb_messages WHERE narrative_id = ? ORDER BY seq").all(nid).map((r) => r.key), ["u1", "a1", "a2", "q1", "u3"]);
433
+ db.close();
434
+ });
435
+ it("treats a fork-copied prefix as a continuation: no duplicate, first new message re's the predecessor", () => {
436
+ const db = createTestDb();
437
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, false);
438
+ const pred = plainSession();
439
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(pred), candidateFor("/x/S.jsonl", "S"))), "inserted");
440
+ const predNid = db.prepare("SELECT id FROM narratives").get().id;
441
+ // The successor's file is the predecessor's records verbatim, then more.
442
+ const succ = [
443
+ ...pred,
444
+ user("u9", "a2", "continuing after the fork", 9000),
445
+ assistant("a9", "u9", [{ type: "text", text: "still here" }], 9500),
446
+ ];
447
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(succ), candidateFor("/x/F.jsonl", "F"))), "inserted");
448
+ const succNid = db.prepare("SELECT id FROM narratives WHERE agent_session_id = 'F'").get().id;
449
+ const events = db.prepare("SELECT seq, content, re FROM events WHERE narrative_id = ? ORDER BY seq").all(succNid);
450
+ assert.deepEqual(events.map((e) => [e.seq, e.content, e.re]), [
451
+ [1, "continuing after the fork", JSON.stringify([`${predNid}:3`])],
452
+ [2, "still here", null],
453
+ ]);
454
+ // The prefix's tool call stayed with the predecessor; none was copied.
455
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM tool_uses WHERE narrative_id = ?").get(succNid).n, 0);
456
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM events").get().n, 5);
457
+ // A pure copy — a fork with nothing after it — is not a narrative at all.
458
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(pred), candidateFor("/x/G.jsonl", "G"))), "skipped");
459
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM narratives").get().n, 2);
460
+ db.close();
461
+ });
462
+ it("never appends to a live narrative that holds the same session id", () => {
463
+ const db = createTestDb();
464
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, agent_session_id) VALUES ('claude-live0001', 'brane-test', 'claude', 'worker', 'active', '/tmp', ?, 'S')").run(Date.now());
465
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, false);
466
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(plainSession()), candidateFor("/x/S.jsonl"))), "inserted");
467
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM events WHERE narrative_id = 'claude-live0001'").get().n, 0);
468
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM narratives WHERE role = 'absorbed'").get().n, 1);
469
+ db.close();
470
+ });
471
+ it("plans a dry run identically and writes nothing", () => {
472
+ const db = createTestDb();
473
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, true);
474
+ assert.equal(absorbOne(ctx, parseClaudeSession(jsonl(plainSession()), candidateFor("/x/S.jsonl"))), "inserted");
475
+ assert.equal(ctx.summary.conversations, 1);
476
+ assert.equal(ctx.summary.messages, 3);
477
+ assert.equal(ctx.summary.toolCalls, 1);
478
+ for (const t of ["narratives", "events", "tool_uses", "absorb_messages"]) {
479
+ assert.equal(db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n, 0, t);
480
+ }
481
+ db.close();
482
+ });
483
+ });
484
+ // ---- classification and the survey ---------------------------------------
485
+ describe("classifySessions / surveyAbsorbSources", () => {
486
+ it("tells managed, absorbed-unchanged, absorbed-changed and new apart", () => {
487
+ const db = createTestDb();
488
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, session_ids) VALUES ('claude-live0001', 'brane-test', 'claude', 'worker', 'active', '/tmp', ?, '[\"M1\",\"M2\"]')").run(Date.now());
489
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, agent_session_id) VALUES ('claude-absorbed', 'brane-claude-1', 'claude', 'absorbed', 'ended', '/tmp', ?, 'A1')").run(Date.now());
490
+ db.prepare("INSERT INTO absorb_files (brane_id, provider, external_id, path, bytes, mtime, absorbed_at, parser_version) VALUES ('brane-claude-1', 'claude', 'A1', '/p', 10, 1000, 1, ?)").run(claudeCodeSource.parserVersion);
491
+ const cands = [
492
+ { externalId: "M2", path: "/p", cwd: null, startedAt: null, updatedAt: 1, bytes: 1 },
493
+ { externalId: "A1", path: "/p", cwd: null, startedAt: null, updatedAt: 1000, bytes: 10 },
494
+ { externalId: "N1", path: "/p", cwd: null, startedAt: null, updatedAt: 1, bytes: 1 },
495
+ ];
496
+ const st = classifySessions(db, claudeCodeSource, cands, "brane-claude-1");
497
+ assert.deepEqual(st.get("M2"), { managedBy: "claude-live0001", absorbedBy: null, unchanged: false, reparse: false });
498
+ assert.deepEqual(st.get("A1"), { managedBy: null, absorbedBy: "claude-absorbed", unchanged: true, reparse: false });
499
+ assert.deepEqual(st.get("N1"), { managedBy: null, absorbedBy: null, unchanged: false, reparse: false });
500
+ cands[1].bytes = 11; // grown
501
+ assert.equal(classifySessions(db, claudeCodeSource, cands, "brane-claude-1").get("A1").unchanged, false);
502
+ cands[1].bytes = 10;
503
+ // The same bytes, last read by an older parser: worth reading again.
504
+ db.prepare("UPDATE absorb_files SET parser_version = ? WHERE external_id = 'A1'").run(claudeCodeSource.parserVersion - 1);
505
+ assert.deepEqual(classifySessions(db, claudeCodeSource, cands, "brane-claude-1").get("A1"), {
506
+ managedBy: null, absorbedBy: "claude-absorbed", unchanged: false, reparse: true,
507
+ });
508
+ // Another brane has absorbed nothing: A1 is new there, M2 is still managed.
509
+ const other = classifySessions(db, claudeCodeSource, cands, "brane-claude-2");
510
+ assert.deepEqual(other.get("A1"), { managedBy: null, absorbedBy: null, unchanged: false, reparse: false });
511
+ assert.equal(other.get("M2").managedBy, "claude-live0001");
512
+ db.close();
513
+ });
514
+ it("surveys a home: counts per source, probes listed only when found, nothing read", () => {
515
+ const db = createTestDb();
516
+ const home = tempStore();
517
+ const root = join(home, ".claude", "projects");
518
+ writeSession(root, "-home-me-dev-app", "aaaa", plainSession());
519
+ writeSession(root, "-home-me-dev-app", "bbbb", plainSession());
520
+ mkdirSync(join(home, ".codex", "sessions", "2026"), { recursive: true });
521
+ const survey = surveyAbsorbSources(db, home);
522
+ const claude = survey.sources.find((s) => s.name === "claude");
523
+ assert.equal(claude.found, true);
524
+ assert.equal(claude.sessions.total, 2);
525
+ assert.equal(claude.sessions.new, 2);
526
+ assert.equal(claude.sessions.folds, 1);
527
+ const codex = survey.sources.find((s) => s.name === "codex");
528
+ assert.equal(codex.found, true);
529
+ assert.equal(codex.supported, false);
530
+ assert.equal(survey.sources.find((s) => s.name === "amp").found, false);
531
+ const lines = formatSurvey(survey);
532
+ assert.ok(lines.some((l) => /Claude Code sessions\s+2 sessions/.test(l)));
533
+ assert.ok(lines.some((l) => l.includes("2 new")));
534
+ assert.ok(lines.some((l) => l.includes("brane absorb claude --dry-run")));
535
+ assert.ok(lines.some((l) => l.includes("Codex CLI rollouts")));
536
+ assert.ok(!lines.some((l) => l.includes("Amp")));
537
+ assert.ok(lines[lines.length - 1].startsWith("Nothing was read or imported."));
538
+ db.close();
539
+ });
540
+ it("surveys a file an older parser read as its own bucket, not as changed", () => {
541
+ const db = createTestDb();
542
+ const home = tempStore();
543
+ const root = join(home, ".claude", "projects");
544
+ const path = writeSession(root, "-home-me-dev-app", "aaaa", plainSession());
545
+ const brane = deriveBraneId("claude", claudeIdentity(home).id);
546
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, agent_session_id) VALUES ('claude-absorbed', ?, 'claude', 'absorbed', 'ended', '/tmp', 1, 'aaaa')").run(brane);
547
+ const st = statSync(path);
548
+ db.prepare("INSERT INTO absorb_files (brane_id, provider, external_id, path, bytes, mtime, absorbed_at, parser_version) VALUES (?, 'claude', 'aaaa', ?, ?, ?, 1, 0)").run(brane, path, st.size, Math.floor(st.mtimeMs));
549
+ const survey = surveyAbsorbSources(db, home);
550
+ const n = survey.sources.find((s) => s.name === "claude").sessions;
551
+ assert.deepEqual([n.new, n.changed, n.unchanged, n.reparse], [0, 0, 0, 1]);
552
+ const lines = formatSurvey(survey);
553
+ assert.ok(lines.some((l) => l.includes("1 to re-read (newer parser)")));
554
+ assert.ok(lines.some((l) => l.includes("brane absorb claude --dry-run")), "worth an import, so the hint is shown");
555
+ db.close();
556
+ });
557
+ });
558
+ // ---- the runner -----------------------------------------------------------
559
+ describe("runAbsorb claude", () => {
560
+ const savedHome = process.env.HOME;
561
+ it("imports a store end to end, skips managed and unchanged on the second run, and honours --session", async () => {
562
+ const db = createTestDb();
563
+ const home = tempStore();
564
+ process.env.HOME = home;
565
+ try {
566
+ const root = join(home, ".claude", "projects");
567
+ writeSession(root, "-home-me-dev-app", "aaaa", plainSession());
568
+ const bPath = writeSession(root, "-home-me-dev-app", "bbbb", [user("b-u1", null, "solo", 0), assistant("b-a1", "b-u1", [{ type: "text", text: "yes" }], 1000)]);
569
+ writeSession(root, "-home-me-dev-app", "mmmm", plainSession());
570
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, agent_session_id) VALUES ('claude-live0001', 'brane-test', 'claude', 'worker', 'active', '/tmp', ?, 'mmmm')").run(Date.now());
571
+ const events = [];
572
+ const emit = (e) => events.push(e);
573
+ const opts = { confineFoldsToHome: false };
574
+ const s1 = await runAbsorb(db, { provider: "claude" }, emit, opts);
575
+ assert.equal(s1.conversations, 2);
576
+ assert.equal(s1.managed, 1);
577
+ assert.equal(s1.unchanged, 0);
578
+ assert.equal(s1.toolCalls, 1);
579
+ const summary = events.find((e) => e.type === "summary");
580
+ assert.equal(summary.type === "summary" && summary.brane_id.startsWith("brane-claude-"), true);
581
+ assert.deepEqual(summary.type === "summary" ? summary.fold_paths : [], ["/home/me/dev/app"]);
582
+ assert.ok(events.some((e) => e.type === "info" && /Managed by brane: 1/.test(e.message)));
583
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM absorb_files").get().n, 2);
584
+ // Second run: both files unchanged → not even read.
585
+ const s2 = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
586
+ assert.equal(s2.conversations, 0);
587
+ assert.equal(s2.unchanged, 2);
588
+ assert.equal(s2.managed, 1);
589
+ // Grow one file: only it is read, and only its tail is appended.
590
+ appendFileSync(bPath, jsonl([user("b-u2", "b-a1", "more", 2000)]));
591
+ utimesSync(bPath, (T0 + 9000) / 1000, (T0 + 9000) / 1000);
592
+ const s3 = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
593
+ assert.equal(s3.appended, 1);
594
+ assert.equal(s3.unchanged, 1);
595
+ assert.equal(s3.messages, 1);
596
+ // --session narrows; a dry run of an unchanged file reports it as such.
597
+ const s4 = await runAbsorb(db, { provider: "claude", session: "aaaa", dry_run: true }, () => { }, opts);
598
+ assert.equal(s4.unchanged, 1);
599
+ assert.equal(s4.conversations, 0);
600
+ // Formatting covers the new counters.
601
+ const lines = formatSummary(s1, false);
602
+ assert.ok(lines.includes("Tool calls: 1"));
603
+ assert.ok(lines.some((l) => l.startsWith("Managed by brane: 1")));
604
+ }
605
+ finally {
606
+ process.env.HOME = savedHome;
607
+ db.close();
608
+ }
609
+ });
610
+ it("treats --brane as an independent destination: two branes, two complete copies, each idempotent", async () => {
611
+ const db = createTestDb();
612
+ const home = tempStore();
613
+ process.env.HOME = home;
614
+ try {
615
+ const root = join(home, ".claude", "projects");
616
+ writeSession(root, "-home-me-dev-app", "aaaa", plainSession());
617
+ const opts = { confineFoldsToHome: false };
618
+ const first = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
619
+ assert.equal(first.conversations, 1);
620
+ const check = await runAbsorb(db, { provider: "claude", brane: "brane-claude-check" }, () => { }, opts);
621
+ assert.equal(check.conversations, 1, "the second brane gets its own copy");
622
+ assert.equal(check.unchanged, 0);
623
+ const rows = db.prepare("SELECT brane_id, COUNT(*) n FROM narratives GROUP BY brane_id ORDER BY brane_id").all();
624
+ assert.equal(rows.length, 2);
625
+ assert.ok(rows.every((r) => r.n === 1));
626
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM events").get().n, 6);
627
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM tool_uses").get().n, 2);
628
+ // Each brane's re-run is a no-op on its own ledger.
629
+ const again = await runAbsorb(db, { provider: "claude", brane: "brane-claude-check" }, () => { }, opts);
630
+ assert.equal(again.unchanged, 1);
631
+ const againFirst = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
632
+ assert.equal(againFirst.unchanged, 1);
633
+ }
634
+ finally {
635
+ process.env.HOME = savedHome;
636
+ db.close();
637
+ }
638
+ });
639
+ it("--rebuild re-derives an absorbed narrative in place: same id, fresh events, index reset, managed untouched", async () => {
640
+ const db = createTestDb();
641
+ const home = tempStore();
642
+ process.env.HOME = home;
643
+ try {
644
+ const root = join(home, ".claude", "projects");
645
+ // A session absorbed under the old rendering: simulate by importing a
646
+ // file whose user turn is a raw task notification (what #456 wrote).
647
+ const note = "<task-notification>\n<task-id>t1</task-id>\n<status>completed</status>\n<summary>done</summary>\n<result>ok</result>\n</task-notification>";
648
+ const recs = [user("u1", null, "first", 0), user("u2", "u1", note, 100), assistant("a1", "u2", [{ type: "text", text: "reply" }], 200)];
649
+ const path = writeSession(root, "-home-me-dev-app", "aaaa", recs);
650
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, agent_session_id) VALUES ('claude-live0001', 'brane-test', 'claude', 'worker', 'active', '/tmp', ?, 'mmmm')").run(Date.now());
651
+ writeSession(root, "-home-me-dev-app", "mmmm", plainSession());
652
+ const opts = { confineFoldsToHome: false };
653
+ const first = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
654
+ assert.equal(first.conversations, 1);
655
+ const nid = db.prepare("SELECT id FROM narratives WHERE role = 'absorbed'").get().id;
656
+ // Pretend the old rendering: rewrite seq 2 to the raw block and index it.
657
+ db.prepare("UPDATE events SET content = ? WHERE narrative_id = ? AND seq = 2").run(note, nid);
658
+ db.prepare("DELETE FROM tool_uses WHERE narrative_id = ?").run(nid);
659
+ db.exec("CREATE TABLE IF NOT EXISTS memory_chunks (id TEXT PRIMARY KEY, narrative_id TEXT NOT NULL, seq INTEGER NOT NULL, content TEXT NOT NULL, time INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS memory_cursors (narrative_id TEXT PRIMARY KEY, last_seq INTEGER NOT NULL)");
660
+ db.prepare("INSERT INTO memory_chunks VALUES ('c1', ?, 2, 'stale', 1)").run(nid);
661
+ db.prepare("INSERT INTO memory_cursors VALUES (?, 3)").run(nid);
662
+ // Retitle the file, and grow it, so the rebuild has something to show.
663
+ appendFileSync(path, jsonl([{ type: "ai-title", aiTitle: "Renamed later", sessionId: "aaaa" }]));
664
+ const dry = await runAbsorb(db, { provider: "claude", rebuild: true, dry_run: true }, () => { }, opts);
665
+ assert.equal(dry.rebuilt, 1);
666
+ assert.equal(db.prepare("SELECT content FROM events WHERE narrative_id = ? AND seq = 2").get(nid).content, note, "dry run wrote nothing");
667
+ const infos = [];
668
+ const s = await runAbsorb(db, { provider: "claude", rebuild: true }, (e) => { if (e.type === "info")
669
+ infos.push(e.message); }, opts);
670
+ assert.equal(s.rebuilt, 1);
671
+ assert.equal(s.managed, 1, "the live narrative is never a rebuild candidate");
672
+ assert.ok(infos.some((l) => /Rebuild: 1/.test(l)));
673
+ const n = db.prepare("SELECT id, title FROM narratives WHERE role = 'absorbed'").get();
674
+ assert.equal(n.id, nid, "same narrative id");
675
+ assert.equal(n.title, "Renamed later", "title refreshed from the file");
676
+ const events = db.prepare("SELECT seq, content FROM events WHERE narrative_id = ? ORDER BY seq").all(nid);
677
+ assert.deepEqual(events.map((e) => [e.seq, e.content]), [[1, "first"], [2, "reply"]], "seqs from 1, notification no longer a message");
678
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM tool_uses WHERE narrative_id = ? AND name = 'task-notification'").get(nid).n, 1);
679
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM memory_chunks WHERE narrative_id = ?").get(nid).n, 0);
680
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM memory_cursors WHERE narrative_id = ?").get(nid).n, 0);
681
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE narrative_id = 'claude-live0001'").get().n, 0);
682
+ // A rebuild of an unchanged store is then a plain re-run.
683
+ const again = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
684
+ assert.equal(again.unchanged, 1);
685
+ assert.equal(again.rebuilt, undefined);
686
+ }
687
+ finally {
688
+ process.env.HOME = savedHome;
689
+ db.close();
690
+ }
691
+ });
692
+ it("reads an unchanged file again when a newer parser could find more, appending what it finds late", async () => {
693
+ const db = createTestDb();
694
+ const home = tempStore();
695
+ process.env.HOME = home;
696
+ try {
697
+ const root = join(home, ".claude", "projects");
698
+ const opts = { confineFoldsToHome: false };
699
+ // An import by a parser that skipped q1, of the file as it now stands.
700
+ const path = writeSession(root, "-home-me-dev-app", "aaaa", midTurnSession().filter((r) => r.uuid !== "q1"));
701
+ await runAbsorb(db, { provider: "claude" }, () => { }, opts);
702
+ writeSession(root, "-home-me-dev-app", "aaaa", midTurnSession());
703
+ const st = statSync(path);
704
+ db.prepare("UPDATE absorb_files SET bytes = ?, mtime = ?, parser_version = 0").run(st.size, Math.floor(st.mtimeMs));
705
+ const s = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
706
+ assert.equal(s.unchanged, 0, "same bytes, older parser: not unchanged");
707
+ assert.equal(s.appended, 1);
708
+ const events = db.prepare("SELECT seq, content, re FROM events ORDER BY seq").all();
709
+ assert.deepEqual(events.map((e) => [e.seq, e.content, e.re]), [
710
+ [1, "Why does the build fail?", null],
711
+ [2, "Let me look.", null],
712
+ [3, "A type error in foo.ts, and the lockfile is fine.", null],
713
+ [4, "also check the lockfile", JSON.stringify([2])],
714
+ ]);
715
+ // Read by the current parser now: the next run skips it on the stat.
716
+ const again = await runAbsorb(db, { provider: "claude" }, () => { }, opts);
717
+ assert.equal(again.unchanged, 1);
718
+ }
719
+ finally {
720
+ process.env.HOME = savedHome;
721
+ db.close();
722
+ }
723
+ });
724
+ it("--rebuild leaves a narrative that was written into locked in: appended to, never re-derived", async () => {
725
+ const db = createTestDb();
726
+ const home = tempStore();
727
+ process.env.HOME = home;
728
+ try {
729
+ const root = join(home, ".claude", "projects");
730
+ const path = writeSession(root, "-home-me-dev-app", "aaaa", plainSession());
731
+ writeSession(root, "-home-me-dev-app", "bbbb", [user("b-u1", null, "solo", 0), assistant("b-a1", "b-u1", [{ type: "text", text: "yes" }], 1000)]);
732
+ const opts = { confineFoldsToHome: false };
733
+ await runAbsorb(db, { provider: "claude" }, () => { }, opts);
734
+ const nid = db.prepare("SELECT id FROM narratives WHERE agent_session_id = 'aaaa'").get().id;
735
+ // The operator signals into the absorbed narrative from the app.
736
+ db.prepare("INSERT INTO events (narrative_id, seq, time, type, content, source) VALUES (?, 4, ?, 'human_signal', 'said in the app', 'web')").run(nid, T0 + 9000);
737
+ appendFileSync(path, jsonl([user("u9", "a2", "grown since", 10000)]));
738
+ const infos = [];
739
+ const s = await runAbsorb(db, { provider: "claude", rebuild: true }, (e) => { if (e.type === "info")
740
+ infos.push(e.message); }, opts);
741
+ assert.equal(s.locked, 1);
742
+ assert.equal(s.rebuilt, 1, "the other narrative, which nobody wrote into, is rebuilt");
743
+ assert.ok(infos.some((l) => /Not rebuilt: 1/.test(l)));
744
+ const events = db.prepare("SELECT seq, content, source FROM events WHERE narrative_id = ? ORDER BY seq").all(nid);
745
+ assert.deepEqual(events.map((e) => [e.seq, e.content]), [
746
+ [1, "Why does the build fail?"], [2, "Let me look."], [3, "A type error in foo.ts."],
747
+ [4, "said in the app"], // survives
748
+ [5, "grown since"], // and the file's growth is still read
749
+ ]);
750
+ assert.ok(formatSummary(s, false).some((l) => l.startsWith("Not rebuilt: 1 (") && l.includes("read as an ordinary re-run")));
751
+ // Again, with the file now unchanged: still locked, counted once.
752
+ const again = await runAbsorb(db, { provider: "claude", rebuild: true, dry_run: true }, () => { }, opts);
753
+ assert.equal(again.locked, 1);
754
+ assert.equal(again.unchanged, 0, "a locked session is not also counted as unchanged");
755
+ assert.ok(formatSummary(again, true).some((l) => l.startsWith("Would not rebuild: 1")));
756
+ }
757
+ finally {
758
+ process.env.HOME = savedHome;
759
+ db.close();
760
+ }
761
+ });
762
+ it("refuses plainly when the store is absent, before emitting", async () => {
763
+ const db = createTestDb();
764
+ const home = tempStore();
765
+ process.env.HOME = home;
766
+ try {
767
+ const emitted = [];
768
+ await assert.rejects(() => runAbsorb(db, { provider: "claude" }, (e) => emitted.push(e), { confineFoldsToHome: false }), (err) => err instanceof AbsorbInputError && /No claude session store/.test(err.message));
769
+ assert.deepEqual(emitted, []);
770
+ }
771
+ finally {
772
+ process.env.HOME = savedHome;
773
+ db.close();
774
+ }
775
+ });
776
+ });
777
+ // ---- what the harness writes into user turns -----------------------------
778
+ describe("classifyUserBlock / harness-written user text", () => {
779
+ it("renders a `!` shell command and its output as code, not tags", () => {
780
+ const recs = [
781
+ user("u1", null, "<bash-input>brane absorb claude</bash-input>", 0),
782
+ user("u2", "u1", "<bash-stdout>Store: ~/.claude/projects\nTo read: 1</bash-stdout><bash-stderr></bash-stderr>", 10),
783
+ user("u3", "u2", "<bash-stdout></bash-stdout><bash-stderr>boom</bash-stderr>", 20),
784
+ ];
785
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
786
+ assert.deepEqual(conv.messages.map((m) => m.content), [
787
+ "```sh\n$ brane absorb claude\n```",
788
+ "```\nStore: ~/.claude/projects\nTo read: 1\n```",
789
+ "stderr:\n```\nboom\n```",
790
+ ]);
791
+ assert.ok(conv.messages.every((m) => m.role === "user"));
792
+ });
793
+ it("renders an interrupt as a quiet marker and a slash command as code", () => {
794
+ const recs = [
795
+ user("u1", null, "[Request interrupted by user for tool use]", 0),
796
+ user("u2", "u1", "<command-name>/model</command-name><command-message>model</command-message><command-args>opus</command-args>", 10),
797
+ user("u3", "u2", "<local-command-stdout>Set model to Opus 5</local-command-stdout>", 20),
798
+ user("u4", "u3", "<local-command-stdout></local-command-stdout>", 30),
799
+ ];
800
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
801
+ assert.deepEqual(conv.messages.map((m) => m.content), [
802
+ "*[Request interrupted by user for tool use]*",
803
+ "`/model opus`",
804
+ "```\nSet model to Opus 5\n```",
805
+ ]);
806
+ });
807
+ it("turns a task notification into a tool row parented to its call, and keeps the typed text beside it", () => {
808
+ const note = "<task-notification>\n<task-id>abc123</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command finished</summary>\n<result>\nall done\n</result>\n</task-notification>";
809
+ const recs = [
810
+ user("u1", null, "run it", 0),
811
+ assistant("a1", "u1", [{ type: "tool_use", id: "toolu_bg", name: "Bash", input: { command: "sleep 1", run_in_background: true } }], 100),
812
+ toolResult("r1", "a1", "toolu_bg", "Command running in background with ID: abc123", 200),
813
+ { ...user("u2", "r1", "", 300), message: { role: "user", content: [{ type: "text", text: note }, { type: "text", text: "and while that ran, a question" }] } },
814
+ { ...user("u3", "u2", "", 400), message: { role: "user", content: [{ type: "text", text: note.replace("completed", "failed") }] } },
815
+ ];
816
+ const conv = parseClaudeSession(jsonl(recs), candidateFor("/x/S.jsonl"));
817
+ assert.deepEqual(conv.messages.map((m) => m.content), ["run it", "and while that ran, a question"]);
818
+ const notes = conv.toolCalls.filter((c) => c.name === "task-notification");
819
+ assert.equal(notes.length, 2);
820
+ assert.deepEqual(notes.map((n) => [n.key, n.status, n.parentKey, n.afterMessage]), [
821
+ ["task:u2", "completed", "toolu_bg", 0],
822
+ ["task:u3", "failed", "toolu_bg", 1],
823
+ ]);
824
+ assert.deepEqual(notes[0].input, { task_id: "abc123", summary: "Background command finished" });
825
+ assert.equal(notes[0].output, "\nall done\n");
826
+ });
827
+ it("fences text that contains backticks with a longer fence", () => {
828
+ const b = classifyUserBlock("<bash-stdout>```js\nx\n```</bash-stdout><bash-stderr></bash-stderr>");
829
+ assert.equal(b.kind, "shell");
830
+ assert.equal(b.text, "````\n```js\nx\n```\n````");
831
+ });
832
+ });
833
+ describe("absorb ledger migration to brane scope", () => {
834
+ it("carries provider-only rows into the brane-scoped tables and keeps the old ones as _v1", () => {
835
+ const db = new Database(":memory:");
836
+ db.exec(SCHEMA_SQL);
837
+ // The #456 shape, populated as an import would have left it.
838
+ db.exec(`
839
+ CREATE TABLE absorb_messages (provider TEXT NOT NULL, key TEXT NOT NULL, narrative_id TEXT NOT NULL, seq INTEGER NOT NULL, PRIMARY KEY (provider, key));
840
+ CREATE TABLE absorb_files (provider TEXT NOT NULL, external_id TEXT NOT NULL, path TEXT NOT NULL, bytes INTEGER NOT NULL, mtime INTEGER NOT NULL, absorbed_at INTEGER NOT NULL, PRIMARY KEY (provider, external_id));
841
+ INSERT INTO narratives (id, brane_id, agent_type, role, status, created_at, agent_session_id) VALUES ('claude-abs00001', 'brane-claude-x', 'claude', 'absorbed', 'ended', 1, 'S1');
842
+ INSERT INTO absorb_messages VALUES ('claude', 'u1', 'claude-abs00001', 1), ('claude', 'orphan', 'claude-gone0000', 1);
843
+ INSERT INTO absorb_files VALUES ('claude', 'S1', '/p/S1.jsonl', 10, 1000, 1), ('claude', 'S2', '/p/S2.jsonl', 5, 500, 1);
844
+ `);
845
+ applyMigrations(db);
846
+ assert.deepEqual(db.prepare("SELECT brane_id, key, narrative_id FROM absorb_messages ORDER BY key").all(), [
847
+ { brane_id: "brane-claude-x", key: "u1", narrative_id: "claude-abs00001" },
848
+ ]);
849
+ assert.deepEqual(db.prepare("SELECT brane_id, external_id, parser_version FROM absorb_files").all(), [
850
+ { brane_id: "brane-claude-x", external_id: "S1", parser_version: 0 }, // read again, once, by the current parser
851
+ ]);
852
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM absorb_messages_v1").get().n, 2);
853
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM absorb_files_v1").get().n, 2);
854
+ applyMigrations(db); // idempotent
855
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM absorb_messages").get().n, 1);
856
+ db.close();
857
+ });
858
+ });
859
+ describe("--rebuild on an export provider", () => {
860
+ it("is refused before anything is emitted, not ignored", async () => {
861
+ const db = createTestDb();
862
+ const root = mkdtempSync(join(tmpdir(), "brane-absorb-rebuild-"));
863
+ const emitted = [];
864
+ await assert.rejects(() => runAbsorb(db, { provider: "chatgpt", path: root, fold: root, rebuild: true }, (e) => emitted.push(e), { confineFoldsToHome: false }), (err) => err instanceof AbsorbInputError && /--rebuild is not supported for the chatgpt provider/.test(err.message));
865
+ assert.deepEqual(emitted, []);
866
+ db.close();
867
+ });
868
+ });
869
+ describe("resetAbsorbedNarrative", () => {
870
+ it("refuses anything that is not the absorbed narrative of that session in that brane", () => {
871
+ const db = createTestDb();
872
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at, agent_session_id) VALUES ('claude-live0001', 'brane-test', 'claude', 'worker', 'active', '/tmp', ?, 'S')").run(Date.now());
873
+ db.prepare("INSERT INTO events (narrative_id, seq, time, type, content) VALUES ('claude-live0001', 1, 1, 'narration', 'keep me')").run();
874
+ assert.throws(() => resetAbsorbedNarrative(db, "claude-live0001", "brane-test", "claude", "S"), /refusing to rebuild/);
875
+ assert.throws(() => resetAbsorbedNarrative(db, "claude-live0001", "brane-other", "claude", "S"), /refusing to rebuild/);
876
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM events").get().n, 1);
877
+ db.close();
878
+ });
879
+ it("refuses an absorbed narrative holding an event no import wrote", () => {
880
+ const db = createTestDb();
881
+ const ctx = createAbsorbContext(db, "claude", "brane-claude-1", "~/store", undefined, false);
882
+ absorbOne(ctx, parseClaudeSession(jsonl(plainSession()), candidateFor("/x/S.jsonl")));
883
+ const nid = db.prepare("SELECT id FROM narratives").get().id;
884
+ assert.equal(liveContributions(db, nid, "brane-claude-1", "claude"), 0);
885
+ db.prepare("INSERT INTO events (narrative_id, seq, time, type, content, source) VALUES (?, 4, 1, 'narration', 'errata', 'claude-other001')").run(nid);
886
+ assert.equal(liveContributions(db, nid, "brane-claude-1", "claude"), 1);
887
+ assert.throws(() => resetAbsorbedNarrative(db, nid, "brane-claude-1", "claude", "S"), /1 event\(s\) were not written by an import/);
888
+ assert.equal(db.prepare("SELECT COUNT(*) n FROM events").get().n, 4, "nothing deleted");
889
+ db.close();
890
+ });
891
+ });
892
+ //# sourceMappingURL=absorb-claude.test.js.map