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,505 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { appendEvent } from "../db/events.js";
5
+ import { exportNarrativeHtml } from "../export.js";
6
+ import { MATH_BLOCK_CSS } from "../shared/markdown-math.js";
7
+ import { speakerColor, speakerBgColor } from "../shared/speaker-color.js";
8
+ // nids follow the `[a-z]+-[0-9a-z]{8}` shape the linkifier recognizes.
9
+ const HOST = "claude-aaaaaaaa";
10
+ const PEER = "goose-bbbbbbbb";
11
+ const GHOST = "codex-cccccccc"; // never inserted → not a known narrative
12
+ function insertNarrative(db, id) {
13
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, created_at, status)
14
+ VALUES (?, 'brane-test', ?, 'worker', 0, 'active')`).run(id, id.split("-")[0]);
15
+ }
16
+ /** A narrative with the fields the header card renders — cost columns arrive by
17
+ * migration, so this doubles as a check that test DBs carry them. */
18
+ function insertCardNarrative(db, id) {
19
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, created_at, status,
20
+ fold_path, summary, cost_amount, cost_unit)
21
+ VALUES (?, 'brane-test', ?, 'twin', ?, 'ended', '~/dev/brane', 'Doing a thing', 26.34, 'USD')`).run(id, id.split("-")[0], Date.now() - 3 * 86_400_000);
22
+ }
23
+ describe("exportNarrativeHtml — colocated interlinking", () => {
24
+ it("links foreign nid:seq refs to a relative <nid>.html#seq-N", async () => {
25
+ const db = createTestDb();
26
+ insertNarrative(db, HOST);
27
+ insertNarrative(db, PEER);
28
+ appendEvent(db, HOST, "narration", { content: `see ${PEER}:9 for context` });
29
+ const html = (await exportNarrativeHtml(db, HOST));
30
+ assert.match(html, new RegExp(`<a href="${PEER}\\.html#seq-9">${PEER}:9</a>`));
31
+ });
32
+ it("links a bare foreign nid to <nid>.html (no anchor)", async () => {
33
+ const db = createTestDb();
34
+ insertNarrative(db, HOST);
35
+ insertNarrative(db, PEER);
36
+ appendEvent(db, HOST, "narration", { content: `related: ${PEER}` });
37
+ const html = (await exportNarrativeHtml(db, HOST));
38
+ assert.match(html, new RegExp(`<a href="${PEER}\\.html">${PEER}</a>`));
39
+ });
40
+ it("keeps self references in-page (#seq-N), not <self>.html", async () => {
41
+ const db = createTestDb();
42
+ insertNarrative(db, HOST);
43
+ appendEvent(db, HOST, "narration", { content: "hello" });
44
+ appendEvent(db, HOST, "narration", { content: `as noted in ${HOST}:1` });
45
+ const html = (await exportNarrativeHtml(db, HOST));
46
+ assert.match(html, new RegExp(`<a href="#seq-1">${HOST}:1</a>`));
47
+ assert.ok(!html.includes(`href="${HOST}.html#seq-1"`), "self ref should not point at its own file");
48
+ });
49
+ it("does not linkify ids that are not known narratives", async () => {
50
+ const db = createTestDb();
51
+ insertNarrative(db, HOST);
52
+ appendEvent(db, HOST, "narration", { content: `unknown ${GHOST}:3 stays plain` });
53
+ const html = (await exportNarrativeHtml(db, HOST));
54
+ assert.ok(!html.includes(`${GHOST}.html`), "unknown id should not become a link");
55
+ assert.ok(html.includes(`${GHOST}:3`), "unknown id text should survive verbatim");
56
+ });
57
+ // Agents write references by hand as `[label](nid:seq)`. Passing those to
58
+ // micromark untouched yields `<a href="">` — it reads `nid:` as an unknown
59
+ // URL scheme and blanks the destination.
60
+ it("rewrites a hand-written [label](nid:seq) destination", async () => {
61
+ const db = createTestDb();
62
+ insertNarrative(db, HOST);
63
+ insertNarrative(db, PEER);
64
+ appendEvent(db, HOST, "narration", { content: `your idea at [${PEER}:34](${PEER}:34)` });
65
+ const html = (await exportNarrativeHtml(db, HOST));
66
+ assert.match(html, new RegExp(`<a href="${PEER}\\.html#seq-34">${PEER}:34</a>`));
67
+ assert.ok(!html.includes('href=""'), "no reference should be left without a destination");
68
+ });
69
+ it("rewrites a hand-written self link, keeping its short label", async () => {
70
+ const db = createTestDb();
71
+ insertNarrative(db, HOST);
72
+ appendEvent(db, HOST, "narration", { content: "hello" });
73
+ appendEvent(db, HOST, "narration", { content: `elaborated at [:1](${HOST}:1)` });
74
+ const html = (await exportNarrativeHtml(db, HOST));
75
+ assert.match(html, /<a href="#seq-1">:1<\/a>/);
76
+ });
77
+ it("rewrites a hand-written brane://nid destination", async () => {
78
+ const db = createTestDb();
79
+ insertNarrative(db, HOST);
80
+ insertNarrative(db, PEER);
81
+ appendEvent(db, HOST, "narration", { content: `see [there](brane://nid/${PEER}/7)` });
82
+ const html = (await exportNarrativeHtml(db, HOST));
83
+ assert.match(html, new RegExp(`<a href="${PEER}\\.html#seq-7">there</a>`));
84
+ });
85
+ it("leaves ordinary markdown links alone", async () => {
86
+ const db = createTestDb();
87
+ insertNarrative(db, HOST);
88
+ insertNarrative(db, PEER);
89
+ appendEvent(db, HOST, "narration", {
90
+ content: `[docs](https://example.com/${PEER}) and [a path](src/export.ts)`,
91
+ });
92
+ const html = (await exportNarrativeHtml(db, HOST));
93
+ assert.match(html, new RegExp(`<a href="https://example.com/${PEER}">docs</a>`));
94
+ assert.match(html, /<a href="src\/export\.ts">a path<\/a>/);
95
+ });
96
+ it("unlinks a hand-written ref to a narrative that does not exist", async () => {
97
+ const db = createTestDb();
98
+ insertNarrative(db, HOST);
99
+ appendEvent(db, HOST, "narration", { content: `dangling [ghost](${GHOST}:2) ref` });
100
+ const html = (await exportNarrativeHtml(db, HOST));
101
+ assert.match(html, /dangling ghost ref/);
102
+ assert.ok(!html.includes('href=""'), "a dead reference should not survive as an empty link");
103
+ });
104
+ it("does not linkify nids inside fenced code blocks", async () => {
105
+ const db = createTestDb();
106
+ insertNarrative(db, HOST);
107
+ insertNarrative(db, PEER);
108
+ appendEvent(db, HOST, "narration", { content: "```\ngrep " + PEER + ":9\n```" });
109
+ const html = (await exportNarrativeHtml(db, HOST));
110
+ assert.ok(!html.includes(`${PEER}.html`), "code-fenced nid should stay plain");
111
+ });
112
+ });
113
+ describe("exportNarrativeHtml — system events", () => {
114
+ it("includes lifecycle events and marks them as system rows", async () => {
115
+ const db = createTestDb();
116
+ insertNarrative(db, HOST);
117
+ appendEvent(db, HOST, "started", { content: "Spawned claude (worker)" });
118
+ appendEvent(db, HOST, "restarted", { content: "Restarted lazygit" });
119
+ appendEvent(db, HOST, "narration", { content: "hello" });
120
+ const html = (await exportNarrativeHtml(db, HOST));
121
+ assert.match(html, /class="row system-row" id="seq-1"/);
122
+ // Role notation is normalized on the way out — see the attribution suite.
123
+ assert.ok(html.includes("Spawned <code>claude</code> /worker"), "spawn event should be exported");
124
+ assert.ok(html.includes("Restarted <code>lazygit</code>"), "restart event should be exported");
125
+ assert.match(html, /class="row agent-row" id="seq-3"/, "narration keeps its own style");
126
+ });
127
+ it("keeps a twin's back-pointer to its predecessor, linkified", async () => {
128
+ const db = createTestDb();
129
+ insertNarrative(db, HOST);
130
+ insertNarrative(db, PEER);
131
+ appendEvent(db, HOST, "started", {
132
+ content: `Twinned from ${PEER}:42`,
133
+ re: [`${PEER}:42`],
134
+ });
135
+ const html = (await exportNarrativeHtml(db, HOST));
136
+ assert.match(html, new RegExp(`<a href="${PEER}\\.html#seq-42">${PEER}:42</a>`));
137
+ // The legacy line gains the agent from the narrative row on the way out.
138
+ assert.ok(html.includes("Spawned <code>claude</code> /twin from "), "twin line should carry the agent");
139
+ });
140
+ it("paints the fold a lifecycle line ends with, and only there", async () => {
141
+ const db = createTestDb();
142
+ insertNarrative(db, HOST);
143
+ db.prepare("UPDATE narratives SET fold_path = ? WHERE id = ?").run("~/dev/brane", HOST);
144
+ appendEvent(db, HOST, "started", { content: "Spawned claude (worker)" });
145
+ appendEvent(db, HOST, "narration", { content: "it is in ~/dev/brane" });
146
+ const html = (await exportNarrativeHtml(db, HOST));
147
+ assert.ok(html.includes('/worker in <a href="brane://fold/~/dev/brane">~/dev/brane</a>'), "a started row carries its fold as the brane://fold link");
148
+ assert.ok(html.includes("it is in ~/dev/brane</p>"), "prose is untouched");
149
+ });
150
+ it("counts only conversational events in the header count", async () => {
151
+ const db = createTestDb();
152
+ insertNarrative(db, HOST);
153
+ appendEvent(db, HOST, "started", { content: "Spawned claude (worker)" });
154
+ appendEvent(db, HOST, "narration", { content: "hello" });
155
+ appendEvent(db, HOST, "human_signal", { content: "hi", source: "web" });
156
+ const html = (await exportNarrativeHtml(db, HOST));
157
+ assert.match(html, /2 messages/);
158
+ });
159
+ });
160
+ describe("exportNarrativeHtml — header card", () => {
161
+ it("renders the narrative card: id, fold, cost, role, activity span, summary", async () => {
162
+ const db = createTestDb();
163
+ insertCardNarrative(db, HOST);
164
+ appendEvent(db, HOST, "narration", { content: "hello" });
165
+ const html = (await exportNarrativeHtml(db, HOST));
166
+ assert.match(html, new RegExp(`<h1>${HOST}</h1>`));
167
+ assert.match(html, /class="fold"><span class="label">in <\/span>~\/dev\/brane/);
168
+ assert.match(html, /class="cost">\$26\.34</);
169
+ assert.match(html, /class="role">\/twin</);
170
+ // Created 3d ago, last event just now → the card's two-ended span.
171
+ assert.match(html, /class="label">from 3d to \d+s ago</);
172
+ assert.match(html, /class="summary">Doing a thing</);
173
+ });
174
+ it("stands in a snapshot glyph for the card's live status, and drops the badge", async () => {
175
+ const db = createTestDb();
176
+ insertCardNarrative(db, HOST);
177
+ appendEvent(db, HOST, "narration", { content: "hello" });
178
+ const html = (await exportNarrativeHtml(db, HOST));
179
+ assert.match(html, /class="snapshot">📸</);
180
+ // Status is live state; a static snapshot must not claim it.
181
+ assert.ok(!/\bDEAD\b|\bLIVE\b|\bIDLE\b/.test(html), "no live status badge in a static export");
182
+ });
183
+ it("anchors the relative span with absolute timestamps in a tooltip", async () => {
184
+ const db = createTestDb();
185
+ insertCardNarrative(db, HOST);
186
+ appendEvent(db, HOST, "narration", { content: "hello" });
187
+ const html = (await exportNarrativeHtml(db, HOST));
188
+ assert.match(html, /<div class="meta-line" title="created [^"]+"/);
189
+ });
190
+ it("omits the meta line entirely when there is nothing to put on it", async () => {
191
+ const db = createTestDb();
192
+ // insertNarrative has no cost and created_at 0 — role survives, so the line
193
+ // still renders; what must not appear is an empty cost/fold artifact.
194
+ insertNarrative(db, HOST);
195
+ appendEvent(db, HOST, "narration", { content: "hello" });
196
+ const html = (await exportNarrativeHtml(db, HOST));
197
+ assert.ok(!html.includes('class="cost"'), "no cost span when cost is absent");
198
+ assert.ok(!html.includes('class="fold"'), "no fold span when fold_path is absent");
199
+ });
200
+ });
201
+ describe("exportNarrativeHtml — operator attribution", () => {
202
+ it("heads an operator row with operator/channel (nid:seq)", async () => {
203
+ const db = createTestDb();
204
+ insertNarrative(db, HOST);
205
+ appendEvent(db, HOST, "human_signal", { content: "hi", source: "web" });
206
+ const html = (await exportNarrativeHtml(db, HOST));
207
+ assert.match(html, new RegExp(`<div class="source"[^>]*><a href="#seq-1">operator/web \\(${HOST}:1\\)</a></div>`));
208
+ // The channel used to ride on the timestamp line; the header owns it now.
209
+ assert.ok(!html.includes("web · "), "channel should not be duplicated onto the timestamp");
210
+ });
211
+ it("falls back to a bare operator header when the channel is unknown", async () => {
212
+ const db = createTestDb();
213
+ insertNarrative(db, HOST);
214
+ appendEvent(db, HOST, "human_signal", { content: "hi" });
215
+ const html = (await exportNarrativeHtml(db, HOST));
216
+ assert.match(html, new RegExp(`<div class="source"[^>]*><a href="#seq-1">operator \\(${HOST}:1\\)</a></div>`));
217
+ });
218
+ });
219
+ describe("exportNarrativeHtml — foreign attribution", () => {
220
+ it("links the speaker's nid to its sibling <speaker>.html and keeps (nid:seq) in-page", async () => {
221
+ const db = createTestDb();
222
+ insertNarrative(db, HOST);
223
+ insertNarrative(db, PEER);
224
+ appendEvent(db, HOST, "agent_signal", { content: "hello from next door", source: PEER });
225
+ const html = (await exportNarrativeHtml(db, HOST));
226
+ assert.match(html, new RegExp(`<div class="source"[^>]*><a href="${PEER}\\.html" class="speaker">${PEER}</a> ` +
227
+ `<a href="#seq-1">\\(${HOST}:1\\)</a></div>`));
228
+ });
229
+ it("writes an unknown speaker as plain text rather than a dead link", async () => {
230
+ const db = createTestDb();
231
+ insertNarrative(db, HOST);
232
+ appendEvent(db, HOST, "agent_signal", { content: "hello", source: GHOST });
233
+ const html = (await exportNarrativeHtml(db, HOST));
234
+ assert.match(html, new RegExp(`<div class="source"[^>]*>${GHOST} <a href="#seq-1">\\(${HOST}:1\\)</a></div>`));
235
+ assert.ok(!html.includes(`${GHOST}.html`), "unknown speaker should not become a link");
236
+ });
237
+ });
238
+ describe("exportNarrativeHtml — system attribution", () => {
239
+ it("heads a system row with system (nid:seq) instead of leaving it anonymous", async () => {
240
+ const db = createTestDb();
241
+ insertNarrative(db, HOST);
242
+ appendEvent(db, HOST, "started", { content: "Spawned claude (worker)" });
243
+ const html = (await exportNarrativeHtml(db, HOST));
244
+ assert.match(html, new RegExp(`<div class="source system-source"><a href="#seq-1">system \\(${HOST}:1\\)</a></div>`));
245
+ });
246
+ it("writes a legacy spawn line in the invocation shape, matching every other surface", async () => {
247
+ const db = createTestDb();
248
+ insertNarrative(db, HOST);
249
+ appendEvent(db, HOST, "started", { content: "Spawned claude (worker)" });
250
+ const html = (await exportNarrativeHtml(db, HOST));
251
+ assert.ok(html.includes("Spawned <code>claude</code> /worker"), "role should render as /worker");
252
+ assert.ok(!html.includes("Spawned claude (worker)"), "parenthesised form should not survive");
253
+ });
254
+ it("rewrites any role, not just the ones brane ships", async () => {
255
+ const db = createTestDb();
256
+ insertNarrative(db, HOST);
257
+ appendEvent(db, HOST, "started", { content: "Spawned claude (haruspex)" });
258
+ const html = (await exportNarrativeHtml(db, HOST));
259
+ assert.ok(html.includes("Spawned <code>claude</code> /haruspex"));
260
+ });
261
+ it("leaves parenthesised prose alone", async () => {
262
+ const db = createTestDb();
263
+ insertNarrative(db, HOST);
264
+ appendEvent(db, HOST, "restarted", {
265
+ content: "Revived claude session (forked — original still live)",
266
+ });
267
+ const html = (await exportNarrativeHtml(db, HOST));
268
+ assert.ok(html.includes("(forked — original still live)"), "multi-word parenthetical is prose, not a role");
269
+ });
270
+ it("does not rewrite roles in what someone actually said", async () => {
271
+ const db = createTestDb();
272
+ insertNarrative(db, HOST);
273
+ appendEvent(db, HOST, "narration", { content: "spawned a helper (worker)" });
274
+ const html = (await exportNarrativeHtml(db, HOST));
275
+ assert.ok(html.includes("(worker)"), "narration content is verbatim");
276
+ });
277
+ });
278
+ /** A wide equation must survive the trip out of the app.
279
+ *
280
+ * The export carries no script, so everything about how display math scrolls
281
+ * — and how it says that it can — has to be in the stylesheet, and has to be
282
+ * the same stylesheet the app injects. See MATH_BLOCK_CSS.
283
+ */
284
+ describe("exportNarrativeHtml — display math", () => {
285
+ const WIDE = "$$\\sum_{n=0}^{\\infty} \\frac{1}{16^n}\\left(\\frac{4}{8n+1} - \\frac{2}{8n+4}\\right)$$";
286
+ it("carries the app's own scroll rules rather than a second copy of them", async () => {
287
+ const db = createTestDb();
288
+ insertNarrative(db, HOST);
289
+ appendEvent(db, HOST, "narration", { content: WIDE });
290
+ const html = (await exportNarrativeHtml(db, HOST));
291
+ assert.ok(html.includes(MATH_BLOCK_CSS), "the shared block is in the document verbatim");
292
+ });
293
+ it("keeps the hint scriptless, since the export has no other option", async () => {
294
+ const db = createTestDb();
295
+ insertNarrative(db, HOST);
296
+ appendEvent(db, HOST, "narration", { content: WIDE });
297
+ const html = (await exportNarrativeHtml(db, HOST));
298
+ const style = html.slice(0, html.indexOf("</style>"));
299
+ // Everything about when an edge fades is decided by a scroll timeline the
300
+ // browser drives itself. An export that had to measure its own overflow
301
+ // would need a script, and then it would no longer be a file that opens
302
+ // anywhere.
303
+ assert.match(style, /animation-timeline: scroll\(self x\)/);
304
+ assert.match(style, /mask-image: linear-gradient/);
305
+ });
306
+ });
307
+ describe("exportNarrativeHtml — foreign speaker colors", () => {
308
+ // The export is a snapshot of what the app shows, and what the app shows for
309
+ // a foreign row is its speaker's hue from `shared/speaker-color` — at the
310
+ // 🎨 Recolor salt the device is viewing with. These pin that the export
311
+ // calls the same functions with the same salt, rather than a lookalike.
312
+ async function foreignRow(palette) {
313
+ const db = createTestDb();
314
+ insertNarrative(db, HOST);
315
+ insertNarrative(db, PEER);
316
+ appendEvent(db, HOST, "agent_signal", { content: "hello from a peer", source: PEER });
317
+ const html = (await exportNarrativeHtml(db, HOST, palette === undefined ? {} : { palette }));
318
+ const bubble = html.match(/<div class="bubble foreign"[^>]*>/)?.[0] ?? "";
319
+ const source = html.match(/<div class="source"[^>]*>/)?.[0] ?? "";
320
+ return { bubble, source };
321
+ }
322
+ it("colors a foreign row with its speaker's default-palette hue", async () => {
323
+ const { bubble, source } = await foreignRow();
324
+ assert.ok(bubble.includes(`style="background:${speakerBgColor(PEER)}"`), bubble);
325
+ assert.ok(source.includes(`style="color:${speakerColor(PEER)}"`), source);
326
+ });
327
+ it("applies the palette salt the request carries", async () => {
328
+ const { bubble, source } = await foreignRow(3);
329
+ assert.ok(bubble.includes(`style="background:${speakerBgColor(PEER, 3)}"`), bubble);
330
+ assert.ok(source.includes(`style="color:${speakerColor(PEER, 3)}"`), source);
331
+ // And it is a different color than the default, or the salt did nothing.
332
+ assert.notEqual(speakerBgColor(PEER, 3), speakerBgColor(PEER));
333
+ });
334
+ it("leaves non-foreign rows uncolored", async () => {
335
+ const db = createTestDb();
336
+ insertNarrative(db, HOST);
337
+ appendEvent(db, HOST, "narration", { content: "mine" });
338
+ appendEvent(db, HOST, "human_signal", { content: "yours", source: "web" });
339
+ const html = (await exportNarrativeHtml(db, HOST, { palette: 3 }));
340
+ assert.ok(!/<div class="bubble (agent|operator)" style=/.test(html), html);
341
+ assert.ok(!/<div class="source" style=/.test(html), html);
342
+ });
343
+ });
344
+ describe("exportNarrativeHtml — selected messages", () => {
345
+ /** Five messages, seqs 1–5, so a selection has something to leave out. */
346
+ function conversation() {
347
+ const db = createTestDb();
348
+ insertNarrative(db, HOST);
349
+ for (const n of [1, 2, 3, 4, 5]) {
350
+ appendEvent(db, HOST, n % 2 === 0 ? "human_signal" : "narration", {
351
+ content: `message ${n}`,
352
+ ...(n % 2 === 0 ? { source: "web" } : {}),
353
+ });
354
+ }
355
+ return db;
356
+ }
357
+ it("names the 💾 download after its count and contents", async () => {
358
+ const full = (await exportNarrativeHtml(conversation(), HOST, { interactive: true }));
359
+ const only = (await exportNarrativeHtml(conversation(), HOST, { interactive: true, seqs: [2] }));
360
+ const except = (await exportNarrativeHtml(conversation(), HOST, { interactive: true, seqs: [2], seqsMode: "except" }));
361
+ assert.match(full, /a\.download = "claude-aaaaaaaa\.html";/);
362
+ assert.match(only, /a\.download = "claude-aaaaaaaa\.1-of-5\.[0-9a-f]{6}\.html";/);
363
+ assert.match(except, /a\.download = "claude-aaaaaaaa\.4-of-5\.[0-9a-f]{6}\.html";/, "neither name says which side was taken");
364
+ });
365
+ it("a half-selection's excerpt and redaction download under different names", async () => {
366
+ const db = createTestDb();
367
+ insertNarrative(db, HOST);
368
+ for (const n of [1, 2, 3, 4])
369
+ appendEvent(db, HOST, "narration", { content: `message ${n}` });
370
+ const name = (html) => /a\.download = "([^"]+)";/.exec(html)[1];
371
+ const only = name((await exportNarrativeHtml(db, HOST, { interactive: true, seqs: [1, 4] })));
372
+ const except = name((await exportNarrativeHtml(db, HOST, { interactive: true, seqs: [1, 4], seqsMode: "except" })));
373
+ assert.match(only, /\.2-of-4\./);
374
+ assert.match(except, /\.2-of-4\./);
375
+ assert.notEqual(only, except);
376
+ });
377
+ it("leaves the summary out of an `except` export, which it may paraphrase", async () => {
378
+ const db = createTestDb();
379
+ insertCardNarrative(db, HOST);
380
+ appendEvent(db, HOST, "narration", { content: "the secret plan" });
381
+ appendEvent(db, HOST, "narration", { content: "the public part" });
382
+ const except = (await exportNarrativeHtml(db, HOST, { seqs: [1], seqsMode: "except" }));
383
+ assert.ok(!except.includes('class="summary"'), "a redacted export carries no summary");
384
+ // Nor does the copy the 💾 button saves, which is embedded as base64.
385
+ const wrapped = (await exportNarrativeHtml(db, HOST, { interactive: true, seqs: [1], seqsMode: "except" }));
386
+ const saved = Buffer.from(/atob\("([^"]+)"\)/.exec(wrapped)[1], "base64").toString("utf8");
387
+ assert.ok(saved.includes("the public part"), "decoded the saved copy");
388
+ assert.ok(!saved.includes('class="summary"'), "the saved copy carries no summary either");
389
+ const only = (await exportNarrativeHtml(db, HOST, { seqs: [2] }));
390
+ assert.match(only, /class="summary">Doing a thing</, "an excerpt keeps it");
391
+ const full = (await exportNarrativeHtml(db, HOST));
392
+ assert.match(full, /class="summary">Doing a thing</);
393
+ });
394
+ it("exports only the selected seqs", async () => {
395
+ const html = (await exportNarrativeHtml(conversation(), HOST, { seqs: [2, 4] }));
396
+ assert.ok(html.includes('id="seq-2"'), "selected seq 2 should be present");
397
+ assert.ok(html.includes('id="seq-4"'), "selected seq 4 should be present");
398
+ for (const absent of [1, 3, 5]) {
399
+ assert.ok(!html.includes(`id="seq-${absent}"`), `seq ${absent} should not be exported`);
400
+ }
401
+ });
402
+ it("keeps #seq-N anchors, so a citation into an excerpt still resolves", async () => {
403
+ const html = (await exportNarrativeHtml(conversation(), HOST, { seqs: [4] }));
404
+ assert.match(html, /<div class="row [a-z]+-row" id="seq-4">/);
405
+ assert.match(html, /<a href="#seq-4"/);
406
+ });
407
+ it("says how many of how many in the header", async () => {
408
+ const html = (await exportNarrativeHtml(conversation(), HOST, { seqs: [2, 4] }));
409
+ assert.match(html, /Exported [^<]* · 2 of 5 messages</);
410
+ });
411
+ it("marks the gap between messages that were not adjacent", async () => {
412
+ const html = (await exportNarrativeHtml(conversation(), HOST, { seqs: [1, 4] }));
413
+ assert.match(html, /<div class="elision">⋯ 2 messages not shown<\/div>/);
414
+ });
415
+ it("singularizes a one-message gap", async () => {
416
+ const html = (await exportNarrativeHtml(conversation(), HOST, { seqs: [1, 3] }));
417
+ assert.match(html, /⋯ 1 message not shown/);
418
+ });
419
+ it("marks no gap between adjacent selections, or before the first", async () => {
420
+ const html = (await exportNarrativeHtml(conversation(), HOST, { seqs: [3, 4] }));
421
+ assert.ok(!html.includes("not shown"), "adjacent picks are adjacent; nothing was skipped between them");
422
+ });
423
+ it("is byte-identical to a full export when nothing is selected", async () => {
424
+ const db = conversation();
425
+ // `Exported <now>` is the one field that moves between two calls, so the
426
+ // comparison strips it rather than racing the clock.
427
+ const strip = (h) => h.replace(/Exported [^<]*</, "Exported <");
428
+ const full = strip((await exportNarrativeHtml(db, HOST)));
429
+ assert.equal(strip((await exportNarrativeHtml(db, HOST, { seqs: [] }))), full);
430
+ assert.equal(strip((await exportNarrativeHtml(db, HOST, { seqs: undefined }))), full);
431
+ });
432
+ it("exports everything but the selection under seqsMode: except", async () => {
433
+ const html = (await exportNarrativeHtml(conversation(), HOST, {
434
+ seqs: [2, 4],
435
+ seqsMode: "except",
436
+ }));
437
+ for (const kept of [1, 3, 5]) {
438
+ assert.ok(html.includes(`id="seq-${kept}"`), `seq ${kept} should be exported`);
439
+ }
440
+ for (const dropped of [2, 4]) {
441
+ assert.ok(!html.includes(`id="seq-${dropped}"`), `seq ${dropped} was excluded`);
442
+ }
443
+ });
444
+ it("discloses a complement the same way it discloses an excerpt", async () => {
445
+ // "3 of 5" whichever side of the selection it is: a reader handed the file
446
+ // can tell that messages are missing, which is the whole of what the line
447
+ // promises. It does not say which ones, because an excerpt never has.
448
+ const html = (await exportNarrativeHtml(conversation(), HOST, {
449
+ seqs: [2, 4],
450
+ seqsMode: "except",
451
+ }));
452
+ assert.match(html, /Exported [^<]* · 3 of 5 messages</);
453
+ });
454
+ it("marks each excluded run as the gap it is", async () => {
455
+ // seqs 2 and 4 removed from 1–5 leaves three messages with a one-message
456
+ // hole between each pair — the same elision row an excerpt gets, for the
457
+ // same reason: two bubbles stacked together would claim adjacency.
458
+ const html = (await exportNarrativeHtml(conversation(), HOST, {
459
+ seqs: [2, 4],
460
+ seqsMode: "except",
461
+ }));
462
+ assert.equal(html.match(/⋯ 1 message not shown/g)?.length, 2);
463
+ });
464
+ it("marks no gap where the excluded messages were at the ends", async () => {
465
+ const html = (await exportNarrativeHtml(conversation(), HOST, {
466
+ seqs: [1, 5],
467
+ seqsMode: "except",
468
+ }));
469
+ assert.ok(!html.includes("not shown"), "2, 3, 4 are adjacent; the header states the rest");
470
+ });
471
+ it("exports the whole conversation when the complement excludes nothing", async () => {
472
+ // No selection means no scope to invert — `except` nothing is everything,
473
+ // and says so as a full export rather than as "5 of 5".
474
+ const db = conversation();
475
+ const strip = (h) => h.replace(/Exported [^<]*</, "Exported <");
476
+ const full = strip((await exportNarrativeHtml(db, HOST)));
477
+ assert.equal(strip((await exportNarrativeHtml(db, HOST, { seqs: [], seqsMode: "except" }))), full);
478
+ });
479
+ it("exports an empty document when the selection is everything", async () => {
480
+ // Honest rather than guarded: the operator asked for the complement of the
481
+ // whole conversation, and the header says it is 0 of 5.
482
+ const html = (await exportNarrativeHtml(conversation(), HOST, {
483
+ seqs: [1, 2, 3, 4, 5],
484
+ seqsMode: "except",
485
+ }));
486
+ assert.match(html, /· 0 of 5 messages</);
487
+ assert.ok(!/id="seq-\d+"/.test(html), "nothing was left to export");
488
+ });
489
+ it("ignores seqsMode: only, which is what an excerpt already was", async () => {
490
+ const db = conversation();
491
+ const strip = (h) => h.replace(/Exported [^<]*</, "Exported <");
492
+ assert.equal(strip((await exportNarrativeHtml(db, HOST, { seqs: [2, 4], seqsMode: "only" }))), strip((await exportNarrativeHtml(db, HOST, { seqs: [2, 4] }))));
493
+ });
494
+ it("exports a selected lifecycle row as itself", async () => {
495
+ const db = createTestDb();
496
+ insertNarrative(db, HOST);
497
+ appendEvent(db, HOST, "started", { content: "Spawned `claude` /worker" });
498
+ appendEvent(db, HOST, "narration", { content: "hello" });
499
+ const html = (await exportNarrativeHtml(db, HOST, { seqs: [1] }));
500
+ assert.match(html, /<div class="bubble system"/);
501
+ // Lifecycle rows are shown but never counted, here as in a full export.
502
+ assert.match(html, /· 0 of 1 messages</);
503
+ });
504
+ });
505
+ //# sourceMappingURL=export.test.js.map