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,166 @@
1
+ import { after, before, describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createTestDb } from "./helpers.js";
7
+ import { appendEvent } from "../db/events.js";
8
+ import { getAgentAdapter } from "../agents.js";
9
+ import { resolvePendingTraces } from "../memory/index.js";
10
+ // meta.session / meta.model per-message provenance resolution (claude).
11
+ //
12
+ // claude's resolveTrace and the runIndex resolver read the agent's own
13
+ // session JSONL under ~/.claude/projects/<cwd-encoded>/<id>.jsonl. We sandbox
14
+ // HOME to a temp dir, fabricate a session log with two assistant turns on
15
+ // *different* models (proving per-message granularity), and assert both the
16
+ // adapter and the DB-level backfill resolve correctly.
17
+ const SESSION = "11111111-2222-3333-4444-555555555555";
18
+ const FOLD = "/work/proj"; // absolute fold path (stored verbatim in narratives)
19
+ const UUID_A = "aaaaaaaa-0000-0000-0000-000000000001";
20
+ const UUID_B = "bbbbbbbb-0000-0000-0000-000000000002";
21
+ const SAY_A = "First narration, on the older model.";
22
+ const SAY_B = "Second narration, after a /model switch.";
23
+ let origHome;
24
+ let homeDir;
25
+ function assistantLine(uuid, model, say) {
26
+ return JSON.stringify({
27
+ type: "assistant",
28
+ uuid,
29
+ message: {
30
+ model,
31
+ role: "assistant",
32
+ content: [{ type: "tool_use", name: "mcp__brane__narrate", input: { say } }],
33
+ },
34
+ });
35
+ }
36
+ before(() => {
37
+ origHome = process.env.HOME;
38
+ homeDir = mkdtempSync(join(tmpdir(), "brane-home-"));
39
+ process.env.HOME = homeDir;
40
+ const projectDir = join(homeDir, ".claude", "projects", FOLD.replace(/\//g, "-"));
41
+ mkdirSync(projectDir, { recursive: true });
42
+ const lines = [
43
+ JSON.stringify({ type: "user", uuid: "user-0", message: { role: "user", content: "hi" } }),
44
+ assistantLine(UUID_A, "claude-opus-4-7", SAY_A),
45
+ assistantLine(UUID_B, "claude-opus-4-8", SAY_B),
46
+ ];
47
+ writeFileSync(join(projectDir, `${SESSION}.jsonl`), lines.join("\n") + "\n");
48
+ });
49
+ after(() => {
50
+ if (origHome === undefined)
51
+ delete process.env.HOME;
52
+ else
53
+ process.env.HOME = origHome;
54
+ });
55
+ describe("claude resolveTrace", () => {
56
+ const claude = getAgentAdapter("claude");
57
+ it("declares per-message granularity", () => {
58
+ assert.equal(claude.modelGranularity, "per-message");
59
+ });
60
+ it("resolves offset (uuid) + model by exact say match, per message", () => {
61
+ const a = claude.resolveTrace({ sessionId: SESSION, foldPath: FOLD, content: SAY_A });
62
+ assert.deepEqual(a, { offset: UUID_A, model: "claude-opus-4-7" });
63
+ const b = claude.resolveTrace({ sessionId: SESSION, foldPath: FOLD, content: SAY_B });
64
+ assert.deepEqual(b, { offset: UUID_B, model: "claude-opus-4-8" });
65
+ });
66
+ it("returns null on no content match and on a missing session file", () => {
67
+ assert.equal(claude.resolveTrace({ sessionId: SESSION, foldPath: FOLD, content: "never said" }), null);
68
+ assert.equal(claude.resolveTrace({ sessionId: "no-such-session", foldPath: FOLD, content: SAY_A }), null);
69
+ });
70
+ });
71
+ describe("resolvePendingTraces (runIndex backfill)", () => {
72
+ function seedNarrative(db, id, opts = {}) {
73
+ db.prepare(`INSERT INTO narratives (id, brane_id, fold_path, agent_type, role, created_at, status, agent_session_id)
74
+ VALUES (?, 'brane-test', ?, ?, 'worker', ?, 'active', ?)`).run(id, FOLD, opts.agent_type ?? "claude", Date.now(), opts.agent_session_id ?? null);
75
+ }
76
+ it("stamps meta.session + meta.model on self narrations, idempotently", async () => {
77
+ const db = createTestDb();
78
+ seedNarrative(db, "claude-A", { agent_session_id: SESSION });
79
+ appendEvent(db, "claude-A", "narration", { content: SAY_B, source: "claude-A" });
80
+ const n = await resolvePendingTraces(db);
81
+ assert.equal(n, 1);
82
+ const row = db.prepare("SELECT meta FROM events WHERE narrative_id = 'claude-A' AND seq = 1").get();
83
+ const meta = JSON.parse(row.meta);
84
+ assert.equal(meta.model, "claude-opus-4-8");
85
+ assert.deepEqual(meta.session, { id: SESSION, offset: UUID_B });
86
+ // Second pass resolves nothing (gated on absence of meta.model).
87
+ assert.equal(await resolvePendingTraces(db), 0);
88
+ });
89
+ it("attributes a FOREIGN narration to its author's session (via source)", async () => {
90
+ const db = createTestDb();
91
+ // Author A (claude, has the session) narrates into host B (any agent).
92
+ seedNarrative(db, "claude-A", { agent_session_id: SESSION });
93
+ seedNarrative(db, "goose-B", { agent_type: "goose" });
94
+ appendEvent(db, "goose-B", "agent_signal", { content: SAY_A, source: "claude-A" });
95
+ assert.equal(await resolvePendingTraces(db), 1);
96
+ const row = db.prepare("SELECT meta FROM events WHERE narrative_id = 'goose-B' AND seq = 1").get();
97
+ const meta = JSON.parse(row.meta);
98
+ assert.equal(meta.model, "claude-opus-4-7"); // author's model, not host's
99
+ assert.equal(meta.session.id, SESSION);
100
+ });
101
+ it("uses live-stamped meta.session.id when present (no agent_session_id needed)", async () => {
102
+ const db = createTestDb();
103
+ seedNarrative(db, "claude-A", { agent_session_id: null });
104
+ appendEvent(db, "claude-A", "narration", {
105
+ content: SAY_A,
106
+ source: "claude-A",
107
+ meta: { session: { id: SESSION } },
108
+ });
109
+ assert.equal(await resolvePendingTraces(db), 1);
110
+ const row = db.prepare("SELECT meta FROM events WHERE narrative_id = 'claude-A' AND seq = 1").get();
111
+ assert.equal(JSON.parse(row.meta).model, "claude-opus-4-7");
112
+ });
113
+ it("skips unsupported agents and unresolvable sessions, leaving meta unset", async () => {
114
+ const db = createTestDb();
115
+ // goose has no resolveTrace → skipped.
116
+ seedNarrative(db, "goose-C", { agent_type: "goose", agent_session_id: "x" });
117
+ appendEvent(db, "goose-C", "narration", { content: SAY_A, source: "goose-C" });
118
+ // claude but no session id discoverable → skipped.
119
+ seedNarrative(db, "claude-D", { agent_session_id: null });
120
+ appendEvent(db, "claude-D", "narration", { content: SAY_A, source: "claude-D" });
121
+ assert.equal(await resolvePendingTraces(db), 0);
122
+ const g = db.prepare("SELECT meta FROM events WHERE narrative_id = 'goose-C' AND seq = 1").get();
123
+ assert.equal(g.meta, null);
124
+ });
125
+ it("parses each session transcript once for many same-session events (memoized)", async () => {
126
+ const db = createTestDb();
127
+ seedNarrative(db, "claude-A", { agent_session_id: SESSION });
128
+ // Two pending events, both authored in the same session transcript.
129
+ appendEvent(db, "claude-A", "narration", { content: SAY_A, source: "claude-A" });
130
+ appendEvent(db, "claude-A", "narration", { content: SAY_B, source: "claude-A" });
131
+ // Spy on the claude adapter's transcript parse to count file reads.
132
+ const claude = getAgentAdapter("claude");
133
+ const orig = claude.buildTraceIndex.bind(claude);
134
+ let parses = 0;
135
+ claude.buildTraceIndex = (opts) => {
136
+ parses++;
137
+ return orig(opts);
138
+ };
139
+ try {
140
+ assert.equal(await resolvePendingTraces(db), 2); // both resolved
141
+ assert.equal(parses, 1); // …from a single transcript parse
142
+ }
143
+ finally {
144
+ claude.buildTraceIndex = orig;
145
+ }
146
+ const a = JSON.parse(db.prepare("SELECT meta FROM events WHERE narrative_id='claude-A' AND seq=1").get().meta);
147
+ const b = JSON.parse(db.prepare("SELECT meta FROM events WHERE narrative_id='claude-A' AND seq=2").get().meta);
148
+ assert.equal(a.model, "claude-opus-4-7");
149
+ assert.equal(b.model, "claude-opus-4-8");
150
+ });
151
+ it("skips a corrupt-meta row without aborting the pass (json_valid gate)", async () => {
152
+ const db = createTestDb();
153
+ seedNarrative(db, "claude-A", { agent_session_id: SESSION });
154
+ // A resolvable event (seq 1) plus one with corrupt meta written directly
155
+ // (seq 2). Without the json_valid CASE guard, json_extract on the corrupt
156
+ // blob would throw and abort the whole SELECT.
157
+ appendEvent(db, "claude-A", "narration", { content: SAY_B, source: "claude-A" });
158
+ appendEvent(db, "claude-A", "narration", { content: SAY_A, source: "claude-A" });
159
+ db.prepare("UPDATE events SET meta = '{bad json' WHERE narrative_id = 'claude-A' AND seq = 2").run();
160
+ // The pass completes; the corrupt row is excluded, the valid one resolves.
161
+ assert.equal(await resolvePendingTraces(db), 1);
162
+ const ok = db.prepare("SELECT meta FROM events WHERE narrative_id = 'claude-A' AND seq = 1").get();
163
+ assert.equal(JSON.parse(ok.meta).model, "claude-opus-4-8");
164
+ });
165
+ });
166
+ //# sourceMappingURL=model-resolution.test.js.map
@@ -0,0 +1,77 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { handleMcpNarrate, toolFlag } from "../daemon/server.js";
5
+ // A narrate that is going to refuse must refuse before it writes anything.
6
+ // The failure this pins is the one an agent can't recover from gracefully: an
7
+ // error returned for words that were in fact posted, which it will reasonably
8
+ // post again.
9
+ const SELF = "claude-aaaaaaaa";
10
+ const OTHER = "claude-bbbbbbbb";
11
+ function db() {
12
+ const d = createTestDb();
13
+ const ins = d.prepare("INSERT INTO narratives (id, brane_id, created_at, status) VALUES (?, 'brane-home', 0, 'active')");
14
+ ins.run(SELF);
15
+ ins.run(OTHER);
16
+ return d;
17
+ }
18
+ const count = (d) => d.prepare("SELECT COUNT(*) AS c FROM events").get().c;
19
+ const summary = (d) => d.prepare("SELECT summary FROM narratives WHERE id = ?").get(SELF).summary;
20
+ describe("narrate refuses before the first write", () => {
21
+ for (const [name, bad] of [
22
+ ["tail", { tail: "abc" }],
23
+ ["before", { before: { self: "nope" } }],
24
+ ["after", { after: [1, 2] }],
25
+ ]) {
26
+ for (const wait of [false, true]) {
27
+ it(`a malformed \`${name}\`${wait ? " with wait" : ""} throws and posts nothing`, () => {
28
+ const d = db();
29
+ assert.throws(() => handleMcpNarrate({ say: "hello", meta: { summary: "changed" }, wait, ...bad }, d, SELF, () => { }), /Invalid|cursor/);
30
+ assert.equal(count(d), 0, "no narration was appended");
31
+ assert.equal(summary(d), null, "no summary was written");
32
+ });
33
+ }
34
+ }
35
+ it("an unknown foreign target refuses before the summary is touched", () => {
36
+ const d = db();
37
+ const r = handleMcpNarrate({ say: "hi", nid: "claude-zzzzzzzz", meta: { summary: "changed" } }, d, SELF, () => { });
38
+ assert.match(r.error ?? "", /Unknown narrative/);
39
+ assert.equal(count(d), 0);
40
+ assert.equal(summary(d), null);
41
+ });
42
+ it("a well-formed call still posts", () => {
43
+ const d = db();
44
+ const r = handleMcpNarrate({ say: "hello", tail: "3" }, d, SELF, () => { });
45
+ assert.equal(r.seq, 1);
46
+ assert.equal(count(d), 1);
47
+ });
48
+ });
49
+ describe("toolFlag", () => {
50
+ it("reads booleans and their string spellings", () => {
51
+ assert.equal(toolFlag(true, false), true);
52
+ assert.equal(toolFlag("true", false), true);
53
+ assert.equal(toolFlag(false, true), false);
54
+ assert.equal(toolFlag("false", true), false, "a string \"false\" must not read as truthy");
55
+ });
56
+ it("falls back on anything else", () => {
57
+ for (const raw of [undefined, null, 1, 0, "yes", "", {}]) {
58
+ assert.equal(toolFlag(raw, true), true);
59
+ assert.equal(toolFlag(raw, false), false);
60
+ }
61
+ });
62
+ it("`strict: \"true\"` refuses what `strict: true` refuses", () => {
63
+ // A narration that won't render as written, so `strict` has something to refuse.
64
+ for (const strict of [true, "true"]) {
65
+ const d = db();
66
+ const r = handleMcpNarrate({ say: "$\\bogusmacro{x}$", strict }, d, SELF, () => { });
67
+ assert.ok(r.error, `strict: ${JSON.stringify(strict)} refuses`);
68
+ assert.equal(count(d), 0, "and posts nothing");
69
+ }
70
+ });
71
+ it("`wait: \"true\"` parks a narrate instead of quietly not waiting", () => {
72
+ const d = db();
73
+ const r = handleMcpNarrate({ say: "hello", after: 0, wait: "true" }, d, SELF, () => { });
74
+ assert.ok(r.park, "the plan comes back, as it does for `wait: true`");
75
+ });
76
+ });
77
+ //# sourceMappingURL=narrate-validate.test.js.map
@@ -0,0 +1,160 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { handleMcpNarrate, narrateWaitCursor, startMcpListen, } from "../daemon/server.js";
5
+ import { appendEvent, readEvents } from "../db/events.js";
6
+ // `narrate({ wait: true })` is a narrate followed by a listen, in one call:
7
+ // one re-read of the agent's context per exchange instead of two. The daemon
8
+ // splits it the same way — `handleMcpNarrate` writes and returns a plan, and
9
+ // the dispatcher hands the plan to `startMcpListen` — so these drive the two
10
+ // halves exactly as the dispatcher does.
11
+ const SELF = "claude-aaaaaaaa";
12
+ const OTHER = "claude-bbbbbbbb";
13
+ const BRANE = "brane-home";
14
+ function fakeClient() {
15
+ const sent = [];
16
+ const ws = {
17
+ readyState: 1, // WebSocket.OPEN
18
+ send: (raw) => {
19
+ const msg = JSON.parse(raw);
20
+ if (msg.type === "tool_result")
21
+ sent.push({ callId: msg.id, result: msg.result });
22
+ },
23
+ };
24
+ return {
25
+ client: { ws: ws, narrativeId: SELF, agentType: "claude", pendingListens: new Map() },
26
+ sent,
27
+ };
28
+ }
29
+ function db() {
30
+ const d = createTestDb();
31
+ const ins = d.prepare("INSERT INTO narratives (id, brane_id, created_at, status) VALUES (?, ?, 0, 'active')");
32
+ ins.run(SELF, BRANE);
33
+ ins.run(OTHER, BRANE);
34
+ return d;
35
+ }
36
+ /** What the dispatcher does with a `narrate` call. */
37
+ function narrateWait(d, c, callId, args, waiters = new Map(), settle = () => { }) {
38
+ const result = handleMcpNarrate({ ...args, wait: true }, d, SELF, () => { });
39
+ if (result.park) {
40
+ const { park, ...extra } = result;
41
+ startMcpListen(c, d, callId, { after: park.after }, waiters, settle, BRANE, {
42
+ omit: park.omit,
43
+ extra,
44
+ });
45
+ }
46
+ return result;
47
+ }
48
+ describe("narrate({ wait: true })", () => {
49
+ it("posts, then parks without handing back its own narration", () => {
50
+ const d = db();
51
+ const { client, sent } = fakeClient();
52
+ appendEvent(d, SELF, "human_signal", { content: "question?", source: "web" });
53
+ narrateWait(d, client, "1", { say: "answer", after: 1 });
54
+ assert.equal(readEvents(d, SELF, { after: 0 }).length, 2, "the narration was written");
55
+ assert.equal(sent.length, 0, "its own narration is not what it was waiting for");
56
+ assert.equal(client.pendingListens.size, 1);
57
+ });
58
+ it("wakes on the next signal, with the narration's nid and seq on the result", () => {
59
+ const d = db();
60
+ const { client, sent } = fakeClient();
61
+ const waiters = new Map();
62
+ const settled = [];
63
+ narrateWait(d, client, "1", { say: "done — anything else?" }, waiters, (_nid, name) => settled.push(name));
64
+ appendEvent(d, SELF, "human_signal", { content: "thanks", source: "web" });
65
+ for (const wake of waiters.get(SELF) ?? [])
66
+ wake();
67
+ assert.equal(sent.length, 1);
68
+ const r = sent[0].result;
69
+ assert.equal(r.nid, SELF);
70
+ assert.equal(r.seq, 1);
71
+ assert.equal(r.timed_out, false);
72
+ assert.deepEqual(r.events.self.map((e) => e.content), ["thanks"]);
73
+ // The row is the narrate's, so it is the narrate's row that settles.
74
+ assert.deepEqual(settled, ["mcp__brane__narrate"]);
75
+ });
76
+ it("returns at once when a signal landed between the cursor and the narration", () => {
77
+ const d = db();
78
+ const { client, sent } = fakeClient();
79
+ appendEvent(d, SELF, "human_signal", { content: "first", source: "web" });
80
+ appendEvent(d, SELF, "human_signal", { content: "actually, wait", source: "web" });
81
+ // The agent had read seq 1 and answered it; seq 2 crossed with the answer.
82
+ narrateWait(d, client, "1", { say: "on it", after: 1 });
83
+ assert.equal(sent.length, 1, "a crossed signal is not something to sleep through");
84
+ assert.equal(sent[0].result.waited_ms, 0);
85
+ assert.deepEqual(sent[0].result.events.self.map((e) => e.content), ["actually, wait"]);
86
+ assert.equal(sent[0].result.seq, 3);
87
+ });
88
+ it("carries rendering warnings through to the woken result", () => {
89
+ const d = db();
90
+ const { client, sent } = fakeClient();
91
+ const waiters = new Map();
92
+ narrateWait(d, client, "1", { say: "The identity $\\bogusmacro{x}$ holds." }, waiters);
93
+ appendEvent(d, SELF, "human_signal", { content: "ok", source: "web" });
94
+ for (const wake of waiters.get(SELF) ?? [])
95
+ wake();
96
+ assert.equal(sent[0].result.warnings?.[0].kind, "math_parse_error");
97
+ });
98
+ it("watches every narrative in a multi-narrative cursor", () => {
99
+ const d = db();
100
+ const { client, sent } = fakeClient();
101
+ const waiters = new Map();
102
+ narrateWait(d, client, "1", { say: "waiting on you both", after: { self: 0, [OTHER]: 0 } }, waiters);
103
+ assert.equal(sent.length, 0, "our own narration does not end the wait");
104
+ appendEvent(d, OTHER, "narration", { content: "worker done", source: OTHER });
105
+ for (const wake of waiters.get(OTHER) ?? [])
106
+ wake();
107
+ assert.equal(sent.length, 1);
108
+ assert.deepEqual(sent[0].result.events[OTHER].map((e) => e.content), ["worker done"]);
109
+ assert.deepEqual(sent[0].result.events.self, []);
110
+ });
111
+ it("narrates into another narrative and waits on both sides of the exchange", () => {
112
+ const d = db();
113
+ const { client, sent } = fakeClient();
114
+ const waiters = new Map();
115
+ appendEvent(d, OTHER, "narration", { content: "earlier", source: OTHER });
116
+ const plan = narrateWait(d, client, "1", { say: "can you take this?", nid: OTHER }, waiters);
117
+ assert.equal(plan.nid, OTHER);
118
+ assert.equal(plan.seq, 2);
119
+ // No `after`: both narratives from their current heads, so neither the
120
+ // history nor our own agent_signal comes back.
121
+ assert.deepEqual(plan.park.after, { self: 0, [OTHER]: 2 });
122
+ assert.equal(sent.length, 0);
123
+ appendEvent(d, OTHER, "narration", { content: "on it", source: OTHER });
124
+ for (const wake of waiters.get(OTHER) ?? [])
125
+ wake();
126
+ assert.deepEqual(sent[0].result.events[OTHER].map((e) => e.content), ["on it"]);
127
+ });
128
+ it("leaves a foreign narration out even when the cursor would include it", () => {
129
+ const d = db();
130
+ const { client, sent } = fakeClient();
131
+ narrateWait(d, client, "1", { say: "ping", nid: OTHER, after: { self: 0, [OTHER]: 0 } });
132
+ assert.equal(sent.length, 0, "our agent_signal in OTHER is not a reply");
133
+ assert.equal(client.pendingListens.size, 1);
134
+ });
135
+ it("with `before` or `tail`, posts as a plain narrate and says it did not wait", () => {
136
+ const d = db();
137
+ for (const extra of [{ tail: 5 }, { before: 3, after: 0 }]) {
138
+ const r = handleMcpNarrate({ say: "hi", wait: true, ...extra }, d, SELF, () => { });
139
+ assert.ok(!("park" in r));
140
+ assert.ok("events" in r, "a receipt, as without wait");
141
+ assert.match(String(r.wait_ignored), /call `listen` next/);
142
+ }
143
+ assert.equal(readEvents(d, SELF, { after: 0 }).length, 2, "both narrations were posted");
144
+ });
145
+ it("accepts any cursor listen accepts, and fills omitted heads", () => {
146
+ assert.deepEqual(narrateWaitCursor({ after: 4 }, SELF), { after: { self: 4 } });
147
+ assert.deepEqual(narrateWaitCursor({ after: { [SELF]: 4, [OTHER]: 1 } }, SELF), { after: { [SELF]: 4, [OTHER]: 1 } });
148
+ assert.deepEqual(narrateWaitCursor({}, SELF), { after: { self: undefined } });
149
+ assert.deepEqual(narrateWaitCursor({ nid: SELF }, SELF), { after: { self: undefined } });
150
+ assert.deepEqual(narrateWaitCursor({ nid: OTHER }, SELF), { after: { self: undefined, [OTHER]: undefined } });
151
+ });
152
+ it("without wait, narrate still answers with a receipt and parks nothing", () => {
153
+ const d = db();
154
+ const r = handleMcpNarrate({ say: "hello", after: 0 }, d, SELF, () => { });
155
+ assert.ok(!("park" in r));
156
+ assert.ok("events" in r);
157
+ assert.ok(!("wait_ignored" in r), "a receipt reads exactly as it always has");
158
+ });
159
+ });
160
+ //# sourceMappingURL=narrate-wait.test.js.map
@@ -0,0 +1,219 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { agentTypeOf, formatActivitySpan, formatCost, formatCostTotals, formatInvocation, formatSystemContent, isSystemEvent, relPart, systemLine, restartedContent, revivedContent, runningContent, spawnedContent, } from "../shared/narrative-format.js";
4
+ // Every case pins `now` explicitly. These functions feed four surfaces (the
5
+ // list card, the page header, the switcher, and the HTML export), so a change
6
+ // in wording here is a change in all four — which is the point of them living
7
+ // in one module, and the reason they're worth pinning.
8
+ // Local-clock fixtures, deliberately: `relPart` formats absolute dates with
9
+ // local getters (`d.getMonth()`, `d.getFullYear()`), so a `Date.UTC` fixture
10
+ // would be constructed on one clock and read on another. Noon hides that
11
+ // almost everywhere — but at UTC+13/+14 the date rolls and the assertions
12
+ // shift by a day, which is how a green CI ships a test that fails in Auckland.
13
+ const NOW = new Date(2026, 6, 28, 12, 0, 0).getTime(); // 2026-07-28T12:00 local
14
+ const SEC = 1000;
15
+ const MIN = 60 * SEC;
16
+ const HOUR = 60 * MIN;
17
+ const DAY = 24 * HOUR;
18
+ describe("relPart", () => {
19
+ it("floors sub-minute to seconds, never to zero", () => {
20
+ assert.deepEqual(relPart(NOW - 30 * SEC, NOW), { val: "30s", ago: true });
21
+ // A just-written event is "1s ago", not "0s ago".
22
+ assert.deepEqual(relPart(NOW, NOW), { val: "1s", ago: true });
23
+ });
24
+ it("steps up through minutes, hours, and days", () => {
25
+ assert.deepEqual(relPart(NOW - 5 * MIN, NOW), { val: "5m", ago: true });
26
+ assert.deepEqual(relPart(NOW - 3 * HOUR, NOW), { val: "3h", ago: true });
27
+ assert.deepEqual(relPart(NOW - 3 * DAY, NOW), { val: "3d", ago: true });
28
+ });
29
+ it("switches to an absolute date past a week, dropping the 'ago' suffix", () => {
30
+ const p = relPart(NOW - 10 * DAY, NOW);
31
+ assert.equal(p.ago, false);
32
+ assert.match(p.val, /^Jul \d+$/);
33
+ });
34
+ it("includes the year only when it differs from the reference year", () => {
35
+ const sameYear = relPart(new Date(2026, 0, 15, 12).getTime(), NOW);
36
+ assert.equal(sameYear.val, "Jan 15");
37
+ const priorYear = relPart(new Date(2025, 0, 15, 12).getTime(), NOW);
38
+ assert.equal(priorYear.val, "Jan 15, 2025");
39
+ });
40
+ });
41
+ describe("formatActivitySpan", () => {
42
+ it("collapses to one value when there is no distinct last activity", () => {
43
+ assert.equal(formatActivitySpan(NOW - 3 * HOUR, null, NOW), "3h ago");
44
+ assert.equal(formatActivitySpan(NOW - 3 * HOUR, undefined, NOW), "3h ago");
45
+ });
46
+ it("treats activity within a minute of creation as no span at all", () => {
47
+ const created = NOW - 3 * HOUR;
48
+ assert.equal(formatActivitySpan(created, created + 30 * SEC, NOW), "3h ago");
49
+ });
50
+ it("shares one 'ago' when both ends are relative", () => {
51
+ assert.equal(formatActivitySpan(NOW - 3 * DAY, NOW - 4 * HOUR, NOW), "3d to 4h ago");
52
+ });
53
+ it("collapses a span whose ends round to the same value", () => {
54
+ assert.equal(formatActivitySpan(NOW - 3 * DAY, NOW - 3 * DAY - 2 * MIN, NOW), "3d ago");
55
+ });
56
+ it("spells out each end when one is an absolute date", () => {
57
+ const span = formatActivitySpan(NOW - 10 * DAY, NOW - 4 * HOUR, NOW);
58
+ assert.match(span, /^Jul \d+ to 4h ago$/);
59
+ });
60
+ it("is anchored to the passed instant, not wall-clock time", () => {
61
+ // The export pins `now` to its snapshot instant; the same inputs must give
62
+ // the same string tomorrow.
63
+ const created = NOW - 3 * DAY;
64
+ assert.equal(formatActivitySpan(created, null, NOW), "3d ago");
65
+ // Four days later the same event is past the week boundary, so it becomes
66
+ // an absolute date and loses the "ago" suffix.
67
+ assert.equal(formatActivitySpan(created, null, NOW + 4 * DAY), "Jul 25");
68
+ });
69
+ });
70
+ describe("formatCostTotals", () => {
71
+ it("lists units side by side, never adding them", () => {
72
+ assert.equal(formatCostTotals([{ unit: "USD", amount: 2433.74 }, { unit: "AIC", amount: 100.2 }]), "$2433.74 · 100.2 AIC");
73
+ });
74
+ it("is null with nothing metered", () => {
75
+ assert.equal(formatCostTotals([]), null);
76
+ assert.equal(formatCostTotals(undefined), null);
77
+ });
78
+ });
79
+ describe("formatCost", () => {
80
+ it("renders USD to two decimals with a leading $", () => {
81
+ assert.equal(formatCost(26.34, "USD"), "$26.34");
82
+ assert.equal(formatCost(6.8, "USD"), "$6.80");
83
+ });
84
+ // Non-USD units group per the *runtime's* locale — `toLocaleString()` is
85
+ // called without one, so a German viewer sees "1.200 AIC". That's deliberate
86
+ // (the app renders for whoever is looking), which makes hardcoding "1,200
87
+ // AIC" a test that fails on a German developer's laptop and passes in CI.
88
+ // Assert the shape — grouped number, space, unit — not one locale's commas.
89
+ it("renders any other unit as a grouped number plus the unit", () => {
90
+ assert.equal(formatCost(1200, "AIC"), `${(1200).toLocaleString()} AIC`);
91
+ assert.ok(/^1\D?200 AIC$/.test(formatCost(1200, "AIC")), "thousands are grouped");
92
+ });
93
+ it("clamps a fractional non-USD amount to two decimals", () => {
94
+ assert.equal(formatCost(1200.567, "AIC"), `${(1200.567).toLocaleString(undefined, { maximumFractionDigits: 2 })} AIC`);
95
+ // Whatever the separators, the fraction is two digits, not three.
96
+ assert.match(formatCost(1200.567, "AIC"), /^1\D?200\D57 AIC$/);
97
+ });
98
+ it("returns null when there is nothing to show", () => {
99
+ assert.equal(formatCost(null, "USD"), null);
100
+ assert.equal(formatCost(undefined, "USD"), null);
101
+ assert.equal(formatCost(26.34, null), null);
102
+ assert.equal(formatCost(26.34, ""), null);
103
+ });
104
+ it("keeps a genuine zero rather than treating it as absent", () => {
105
+ assert.equal(formatCost(0, "USD"), "$0.00");
106
+ });
107
+ });
108
+ describe("isSystemEvent", () => {
109
+ it("is false for everything anyone actually said", () => {
110
+ for (const t of ["narration", "human_signal", "agent_signal"]) {
111
+ assert.equal(isSystemEvent(t), false, t);
112
+ }
113
+ });
114
+ it("is true for the daemon's own lifecycle events", () => {
115
+ for (const t of ["started", "restarted", "binding_rejected"]) {
116
+ assert.equal(isSystemEvent(t), true, t);
117
+ }
118
+ });
119
+ it("treats an unknown type as system — the safe default", () => {
120
+ // A new lifecycle type should render as a system row on day one, not as
121
+ // something an agent said.
122
+ assert.equal(isSystemEvent("some_future_type"), true);
123
+ });
124
+ });
125
+ describe("lifecycle lines", () => {
126
+ // The line every narrative opens with, in the shape every writer shares:
127
+ // verb, invocation as a code span, role as a /protocol.
128
+ it("spawn: the operator's invocation and the role", () => {
129
+ assert.equal(spawnedContent("claude", "worker"), "Spawned `claude` /worker");
130
+ assert.equal(spawnedContent("claude", "worker", { args: ["--model", "opus-5", "--dangerously-skip-permissions"] }), "Spawned `claude --model opus-5 --dangerously-skip-permissions` /worker");
131
+ });
132
+ it("twin: the predecessor stays a bare nid:seq so it linkifies", () => {
133
+ assert.equal(spawnedContent("claude", "twin", { from: "claude-aaaaaaaa:42" }), "Spawned `claude` /twin from claude-aaaaaaaa:42");
134
+ });
135
+ it("revive: same shape, flagged when the resume forked a live original", () => {
136
+ assert.equal(revivedContent("claude", "worker"), "Revived `claude` /worker");
137
+ assert.equal(revivedContent("claude", "keeper", ["--dangerously-skip-permissions"], true), "Revived `claude --dangerously-skip-permissions` /keeper (forked — original still live)");
138
+ });
139
+ it("utility: the command is the whole line", () => {
140
+ assert.equal(runningContent(["lazygit"]), "Running `lazygit`");
141
+ assert.equal(restartedContent(["htop", "-d", "5"]), "Restarted `htop -d 5`");
142
+ });
143
+ it("quotes an argument the way a shell would want it typed", () => {
144
+ assert.equal(formatInvocation(["codex", "-c", 'project_root_markers=[".git"]']), "`codex -c 'project_root_markers=[\".git\"]'`");
145
+ assert.equal(formatInvocation(["sh", "-c", "echo it's"]), "`sh -c 'echo it'\\''s'`");
146
+ });
147
+ it("widens the code fence around a backtick instead of breaking it", () => {
148
+ assert.equal(formatInvocation(["echo", "a`b"]), "`` echo 'a`b' ``");
149
+ });
150
+ });
151
+ describe("formatSystemContent", () => {
152
+ // Rows written before the invocation shape existed, brought up to it at
153
+ // render time. Each is anchored to the whole line.
154
+ it("rewrites a legacy spawn line: code-span the agent, slash the role", () => {
155
+ assert.equal(formatSystemContent("Spawned claude (worker)"), "Spawned `claude` /worker");
156
+ });
157
+ it("accepts any role, not just the ones brane ships", () => {
158
+ assert.equal(formatSystemContent("Spawned claude (haruspex)"), "Spawned `claude` /haruspex");
159
+ assert.equal(formatSystemContent("Spawned claude (twin-2)"), "Spawned `claude` /twin-2");
160
+ });
161
+ it("rewrites the old twin back-pointer into a full spawn line, given the agent", () => {
162
+ assert.equal(formatSystemContent("Twinned from claude-aaaaaaaa:42", "goose"), "Spawned `goose` /twin from claude-aaaaaaaa:42");
163
+ // The old line never recorded the agent; without one, the protocol alone.
164
+ assert.equal(formatSystemContent("Twinned from claude-aaaaaaaa:42"), "/twin from claude-aaaaaaaa:42");
165
+ });
166
+ it("reads the agent off a spawned narrative id", () => {
167
+ assert.equal(agentTypeOf("claude-rfhbth2v"), "claude");
168
+ assert.equal(agentTypeOf("my-agent-0a1b2c3d"), "my-agent");
169
+ });
170
+ it("rewrites a legacy revive, keeping its parenthesised prose", () => {
171
+ assert.equal(formatSystemContent("Revived claude session"), "Revived `claude`");
172
+ assert.equal(formatSystemContent("Revived claude session (forked — original still live)"), "Revived `claude` (forked — original still live)");
173
+ });
174
+ it("rewrites a legacy utility line", () => {
175
+ assert.equal(formatSystemContent("Running lazygit"), "Running `lazygit`");
176
+ assert.equal(formatSystemContent("Restarted htop -d 5"), "Restarted `htop -d 5`");
177
+ });
178
+ it("is idempotent: a line already in the new shape passes through", () => {
179
+ for (const s of [
180
+ spawnedContent("claude", "worker", { args: ["--model", "opus-5"] }),
181
+ spawnedContent("claude", "twin", { from: "claude-aaaaaaaa:42" }),
182
+ revivedContent("claude", "worker", [], true),
183
+ runningContent(["lazygit"]),
184
+ ]) {
185
+ assert.equal(formatSystemContent(s, "goose"), s);
186
+ }
187
+ });
188
+ it("only rewrites a whole line — prose that starts with a verb is left alone", () => {
189
+ const s = "Spawned claude (worker) after a restart";
190
+ assert.equal(formatSystemContent(s), s);
191
+ });
192
+ it("passes through content with no legacy shape", () => {
193
+ assert.equal(formatSystemContent("Connected external MCP client"), "Connected external MCP client");
194
+ });
195
+ it("tolerates trailing whitespace", () => {
196
+ assert.equal(formatSystemContent("Spawned claude (worker) "), "Spawned `claude` /worker");
197
+ });
198
+ });
199
+ describe("systemLine", () => {
200
+ // The fold is a column on the narrative row and whether to show it is the
201
+ // event type — neither is read out of the text.
202
+ it("appends the row's fold to a started line as a brane://fold link", () => {
203
+ assert.equal(systemLine({ type: "started", content: "Spawned `claude` /worker" }, { fold: "~/dev/brane" }), "Spawned `claude` /worker in [~/dev/brane](brane://fold/~/dev/brane)");
204
+ assert.equal(systemLine({ type: "started", content: "Running `lazygit`" }, { fold: "~/dev/brane" }), "Running `lazygit` in [~/dev/brane](brane://fold/~/dev/brane)");
205
+ });
206
+ it("leaves a restarted line and a narrative without a fold alone", () => {
207
+ assert.equal(systemLine({ type: "restarted", content: "Revived `claude` /worker" }, { fold: "~/dev/brane" }), "Revived `claude` /worker");
208
+ assert.equal(systemLine({ type: "started", content: "Connected external MCP client" }, {}), "Connected external MCP client");
209
+ });
210
+ it("normalizes a legacy line first, then appends the fold", () => {
211
+ assert.equal(systemLine({ type: "started", content: "Twinned from claude-aaaaaaaa:42" }, { agent: "claude", fold: "~/dev/brane" }), "Spawned `claude` /twin from claude-aaaaaaaa:42 in [~/dev/brane](brane://fold/~/dev/brane)");
212
+ });
213
+ it("drops a fold the interim writers baked in, by equality with the row's own", () => {
214
+ assert.equal(systemLine({ type: "started", content: "Spawned `claude` /worker in ~/dev/brane" }, { fold: "~/dev/brane" }), "Spawned `claude` /worker in [~/dev/brane](brane://fold/~/dev/brane)");
215
+ // A different trailing path is content, not the baked fold.
216
+ assert.equal(systemLine({ type: "started", content: "Spawned `claude` /worker in ~/dev/other" }, { fold: "~/dev/brane" }), "Spawned `claude` /worker in ~/dev/other in [~/dev/brane](brane://fold/~/dev/brane)");
217
+ });
218
+ });
219
+ //# sourceMappingURL=narrative-format.test.js.map