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
package/dist/export.js ADDED
@@ -0,0 +1,629 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname, resolve } from "node:path";
3
+ import { micromark } from "micromark";
4
+ import { gfmTable, gfmTableHtml } from "micromark-extension-gfm-table";
5
+ import { gfmAutolinkLiteral, gfmAutolinkLiteralHtml } from "micromark-extension-gfm-autolink-literal";
6
+ import { gfmTaskListItem, gfmTaskListItemHtml } from "micromark-extension-gfm-task-list-item";
7
+ import { math } from "micromark-extension-math";
8
+ import { NID_PATTERN, NID_REF_PATTERN } from "./shared/markdown-nids.js";
9
+ import { isInlinePayload, INLINE_PAYLOAD_MIME } from "./shared/payload-inline.js";
10
+ import { readEvents, readEventsBySeqs } from "./db/events.js";
11
+ import { exportFileName } from "./shared/export-selection.js";
12
+ import { getPayloadsDir } from "./db/schema.js";
13
+ import { highlightHtml } from "./highlight.js";
14
+ import { renderDiagram } from "./diagram/index.js";
15
+ import { nidHrefFor } from "./diagram/links.js";
16
+ import { mathHtmlExtension, katexStyleFor } from "./math.js";
17
+ import { MATH_BLOCK_CSS, prepareMathMarkdown } from "./shared/markdown-math.js";
18
+ import { DIAGRAM_EDGE_HIGHLIGHT_CSS } from "./shared/diagram-css.js";
19
+ import { speakerColor, speakerBgColor } from "./shared/speaker-color.js";
20
+ import { formatActivitySpan, formatCost, systemLine, FOLD_COLOR, FOLD_LINK_SCHEME, isSystemEvent, CONVERSATIONAL_EVENT_TYPES_LIST, } from "./shared/narrative-format.js";
21
+ // Which payloads are images — and the type each embeds as — is the same rule
22
+ // the app renders by and the daemon serves by: `src/shared/payload-inline.ts`.
23
+ // Kept as one table there because a filter that disagrees with the type map
24
+ // drops an image from the export without saying anything.
25
+ const isImagePayload = isInlinePayload;
26
+ // Shape of an autolink literal, per GFM. Only needed to *avoid* rewriting one:
27
+ // micromark turns these into links on its own (see renderMarkdown), and a URL
28
+ // whose path happens to contain a narrative id — say
29
+ // https://brane.organelle.dev/claude-4c7289d1 — must survive intact.
30
+ const AUTOLINK_LITERAL = "(?:https?://|www\\.)[^\\s<]+";
31
+ // The two href forms an agent writes a reference as by hand — a bare `nid:seq`
32
+ // and the canonical `brane://nid/<nid>/<seq>`. Both are what expo's Markdown
33
+ // renderer accepts (components/Markdown.tsx), so both turn up in stored content.
34
+ const HAND_WRITTEN_REF = new RegExp(`^(?:brane://nid/(${NID_PATTERN})(?:/(\\d+))?|${NID_REF_PATTERN})$`);
35
+ /** Where a reference points within a colocated directory of `<nid>.html` files.
36
+ * Self references stay in-page (`#seq-N`) so they resolve even when the file
37
+ * stands alone; foreign references point at the sibling file. */
38
+ function refHref(nid, seq, selfNid) {
39
+ if (nid === selfNid)
40
+ return seq ? `#seq-${seq}` : `${nid}.html`;
41
+ return `${nid}.html${seq ? `#seq-${seq}` : ""}`;
42
+ }
43
+ // Turn `nid` / `nid:seq` references in message markdown into relative links so
44
+ // that a directory of exported `<nid>.html` files interlinks. Only references
45
+ // to narratives that actually exist (knownIds) are linked, so arbitrary strings
46
+ // matching the id shape aren't turned into dead links. Skips code fences and
47
+ // bare URLs, matching the live app's linkifier.
48
+ //
49
+ // Markdown links are not skipped outright: a link an agent wrote by hand as
50
+ // `[label](claude-4c7289d1:42)` needs its *destination* rewritten, because
51
+ // micromark sanitizes hrefs against a protocol allowlist and reads `nid:` as an
52
+ // unknown scheme — leaving `<a href="">`. Links to anything else pass through.
53
+ function linkifyNarrativeRefs(md, knownIds, selfNid) {
54
+ if (knownIds.size === 0)
55
+ return md;
56
+ const re = new RegExp(`(` +
57
+ "```[\\s\\S]*?```" +
58
+ "|" + AUTOLINK_LITERAL +
59
+ `)` +
60
+ "|\\[([^\\]]*?)\\]\\(([^)]*?)\\)" +
61
+ "|`" + NID_REF_PATTERN + "`" +
62
+ "|" + NID_REF_PATTERN, "g");
63
+ return md.replace(re, (match, skip, label, dest, btNid, btSeq, bareNid, bareSeq) => {
64
+ if (skip)
65
+ return skip;
66
+ if (dest !== undefined) {
67
+ const ref = dest.match(HAND_WRITTEN_REF);
68
+ if (!ref)
69
+ return match;
70
+ // Groups 1/2 are the `brane://` form, 3/4 the bare one; only one fires.
71
+ const nid = ref[1] ?? ref[3];
72
+ const seq = ref[1] ? ref[2] : ref[4];
73
+ // An unknown nid has nothing to point at, and leaving the link in place
74
+ // would only hand micromark another href to blank. Keep the label as
75
+ // prose instead — same policy the bare-reference branch follows.
76
+ if (!knownIds.has(nid))
77
+ return label;
78
+ return `[${label}](${refHref(nid, seq, selfNid)})`;
79
+ }
80
+ const nid = btNid || bareNid;
81
+ const seq = btSeq || bareSeq;
82
+ if (!nid || !knownIds.has(nid))
83
+ return match;
84
+ return `[${seq ? `${nid}:${seq}` : nid}](${refHref(nid, seq, selfNid)})`;
85
+ });
86
+ }
87
+ function escHtml(s) {
88
+ return s
89
+ .replace(/&/g, "&amp;")
90
+ .replace(/</g, "&lt;")
91
+ .replace(/>/g, "&gt;")
92
+ .replace(/"/g, "&quot;");
93
+ }
94
+ function formatTimestamp(ms) {
95
+ return new Date(ms).toLocaleString();
96
+ }
97
+ // micromark HTML-escapes code content on output. Reverse that before feeding
98
+ // to Shiki, which wants raw source.
99
+ function unescapeMicromarkHtml(s) {
100
+ return s
101
+ .replace(/&lt;/g, "<")
102
+ .replace(/&gt;/g, ">")
103
+ .replace(/&quot;/g, '"')
104
+ .replace(/&#39;/g, "'")
105
+ .replace(/&amp;/g, "&");
106
+ }
107
+ /** `trusted` keeps hrefs micromark would otherwise blank as unknown schemes.
108
+ * It is set only for system rows, whose markdown the daemon wrote itself, so
109
+ * the `brane://fold/…` link `systemLine` builds survives to be styled; the
110
+ * default stays strict for anything a person or agent said. */
111
+ async function renderMarkdown(md, opts = {}) {
112
+ // prepareMathMarkdown runs before the parse, not after: micromark-extension-math
113
+ // reads `$10k–$500k` as math over `10k–` and offers no veto once it has
114
+ // tokenized, so the dollars that aren't delimiters get escaped up front. The
115
+ // app runs the same function on the same string — see shared/markdown-math.ts.
116
+ const html = micromark(prepareMathMarkdown(md), {
117
+ allowDangerousProtocol: opts.trusted === true,
118
+ extensions: [gfmTable(), gfmAutolinkLiteral(), gfmTaskListItem(), math()],
119
+ htmlExtensions: [
120
+ gfmTableHtml(),
121
+ gfmAutolinkLiteralHtml(),
122
+ gfmTaskListItemHtml(),
123
+ mathHtmlExtension(),
124
+ ],
125
+ });
126
+ // Collect every fenced block with a known language, highlight in parallel,
127
+ // then substitute back. Works because fenced code blocks can't nest and
128
+ // micromark emits exactly <pre><code class="language-XXX">…</code></pre>.
129
+ const re = /<pre><code class="language-([\w+-]+)">([\s\S]*?)<\/code><\/pre>/g;
130
+ const jobs = [];
131
+ for (const m of html.matchAll(re)) {
132
+ const [match, lang, encoded] = m;
133
+ const raw = unescapeMicromarkHtml(encoded);
134
+ // A mermaid fence draws instead of being highlighted — the *same*
135
+ // `renderDiagram` the app reaches through POST /api/diagram, so an export
136
+ // is a snapshot of the live view rather than a lookalike of it. The SVG is
137
+ // static: no script, which is the export's whole constraint.
138
+ //
139
+ // Nothing else changes, because this loop already substitutes only where
140
+ // the result is non-null. A diagram brane doesn't draw therefore lands as
141
+ // the highlighted code block it would have been anyway.
142
+ if (lang === "mermaid") {
143
+ const drawn = renderDiagram(raw, { hrefFor: opts.diagramHref, edgeHighlight: true });
144
+ jobs.push({
145
+ match,
146
+ promise: Promise.resolve(drawn ? `<div class="diagram">${drawn.svg}</div>` : await highlightHtml(raw, lang)),
147
+ });
148
+ continue;
149
+ }
150
+ jobs.push({ match, promise: highlightHtml(raw, lang) });
151
+ }
152
+ if (jobs.length === 0)
153
+ return html;
154
+ const results = await Promise.all(jobs.map((j) => j.promise));
155
+ let out = html;
156
+ jobs.forEach((job, i) => {
157
+ const replacement = results[i];
158
+ if (replacement)
159
+ out = out.replace(job.match, replacement);
160
+ });
161
+ return out;
162
+ }
163
+ async function loadImageDataUri(nid, relPath) {
164
+ try {
165
+ // Payload paths are stored as "nid/filename"; getPayloadsDir returns the
166
+ // per-narrative dir, so we only need the basename portion that follows.
167
+ const parts = relPath.split("/");
168
+ const filename = parts[parts.length - 1];
169
+ const dir = getPayloadsDir(nid);
170
+ const target = resolve(dir, filename);
171
+ if (!target.startsWith(dir))
172
+ return null;
173
+ const ext = extname(filename).toLowerCase();
174
+ const mime = INLINE_PAYLOAD_MIME[ext];
175
+ if (!mime)
176
+ return null;
177
+ const buf = await readFile(target);
178
+ return `data:${mime};base64,${buf.toString("base64")}`;
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ /** How many *messages* this narrative holds in a seq range — conversational
185
+ * types only, and only those with something to show, which is exactly what
186
+ * the document's own count means by "messages". Asked of SQLite rather than
187
+ * of a materialized event list: an excerpt must not have to build the
188
+ * conversation in order to say how much of it it left out.
189
+ *
190
+ * `after` / `before` are exclusive, matching `readEvents`. */
191
+ function countMessages(db, nid, range = {}) {
192
+ const types = CONVERSATIONAL_EVENT_TYPES_LIST.map(() => "?").join(",");
193
+ const conditions = [
194
+ "narrative_id = ?",
195
+ `type IN (${types})`,
196
+ // The has-something-to-show rule the render pass applies, in SQL. `payloads`
197
+ // is a JSON array or NULL; `appendEvent` writes NULL rather than "[]", and
198
+ // the literal is matched anyway so a hand-written row can't slip past.
199
+ "((content IS NOT NULL AND content != '') OR (payloads IS NOT NULL AND payloads NOT IN ('', '[]')))",
200
+ ];
201
+ const params = [nid, ...CONVERSATIONAL_EVENT_TYPES_LIST];
202
+ if (range.after !== undefined) {
203
+ conditions.push("seq > ?");
204
+ params.push(range.after);
205
+ }
206
+ if (range.before !== undefined) {
207
+ conditions.push("seq < ?");
208
+ params.push(range.before);
209
+ }
210
+ const row = db
211
+ .prepare(`SELECT COUNT(*) AS n FROM events WHERE ${conditions.join(" AND ")}`)
212
+ .get(...params);
213
+ return row.n;
214
+ }
215
+ export async function exportNarrativeHtml(db, nid, opts = {}) {
216
+ // `n.*` plus the newest-event time, exactly as the daemon's list endpoint
217
+ // builds a card's data (server.ts) — the header below is that same card.
218
+ const narrative = db
219
+ .prepare(`SELECT n.*, (
220
+ SELECT time FROM events WHERE narrative_id = n.id ORDER BY seq DESC LIMIT 1
221
+ ) AS last_activity FROM narratives n WHERE n.id = ?`)
222
+ .get(nid);
223
+ if (!narrative)
224
+ return null;
225
+ const selectedSeqs = opts.seqs && opts.seqs.length > 0
226
+ ? [...new Set(opts.seqs)].sort((a, b) => a - b)
227
+ : null;
228
+ // Which side of the selection this export is. `except` inverts it: the same
229
+ // taps, read as "everything but these".
230
+ const excluding = selectedSeqs !== null && opts.seqsMode === "except";
231
+ // An excerpt reads *only what it exports*. `seq IN (…)` is an index lookup
232
+ // per selected message, so exporting two messages out of ten thousand costs
233
+ // two rows — where reading the conversation and filtering it afterwards cost
234
+ // ten thousand, every one of them with its JSON columns parsed.
235
+ //
236
+ // The complement has no such shortcut and wants none: it exports nearly the
237
+ // whole conversation, so it reads the whole conversation and drops the
238
+ // handful of rows the operator picked. That is a full export's cost, which
239
+ // is what this is.
240
+ const excluded = excluding ? new Set(selectedSeqs) : null;
241
+ const raw = selectedSeqs
242
+ ? excluded
243
+ ? readEvents(db, nid, { after: 0 }).filter((e) => !excluded.has(e.seq))
244
+ : readEventsBySeqs(db, nid, selectedSeqs)
245
+ : readEvents(db, nid, { after: 0 });
246
+ // How many messages the narrative actually holds, for the footer's "3 of 47".
247
+ // A count, asked of SQLite, not a length of something built. Both scopes are
248
+ // excerpts and both disclose the same way — "44 of 47" is as much an excerpt
249
+ // as "3 of 47", and a reader handed the file can tell neither how it was
250
+ // chosen nor that they should care.
251
+ const totalMessages = selectedSeqs ? countMessages(db, nid) : 0;
252
+ // Every event with something to show is exported, including lifecycle types
253
+ // (`started`, `restarted`, `binding_rejected`) — they carry real context, like
254
+ // the `/twin from <nid>:<seq>` back-pointer that opens a twin's narrative. A
255
+ // selected row that turns out to be empty is dropped by this same rule, so an
256
+ // excerpt is a subset of the full document rather than a different one.
257
+ const events = raw
258
+ .filter((e) => e.content || (e.payloads && e.payloads.length > 0))
259
+ .map((e) => ({
260
+ seq: e.seq,
261
+ time: e.time,
262
+ type: e.type,
263
+ content: e.content ?? null,
264
+ source: e.source ?? null,
265
+ payloads: e.payloads ?? null,
266
+ }));
267
+ // Inline image payloads as data URIs; non-image payloads render as file-name chips.
268
+ const imagePaths = new Set();
269
+ for (const e of events) {
270
+ for (const p of e.payloads ?? []) {
271
+ if (isImagePayload(p))
272
+ imagePaths.add(p);
273
+ }
274
+ }
275
+ const imageDataUris = new Map();
276
+ await Promise.all(Array.from(imagePaths).map(async (p) => {
277
+ const uri = await loadImageDataUri(nid, p);
278
+ if (uri)
279
+ imageDataUris.set(p, uri);
280
+ }));
281
+ // Known narrative ids in this daemon — used to gate which references become
282
+ // links, so exports dropped into one directory interlink without producing
283
+ // dead links for strings that merely resemble an id.
284
+ const knownIds = new Set(db.prepare("SELECT id FROM narratives").all().map((r) => r.id));
285
+ // One resolver for the whole document, so its existence lookups are
286
+ // memoised across every diagram in the conversation.
287
+ //
288
+ // It points at the sibling `<nid>.html` files the way prose references
289
+ // already do, *not* at `brane://nid/…`: that scheme is the app's in-band
290
+ // signal and no browser opens it, so a diagram node in a downloaded export
291
+ // would have been a link that does nothing.
292
+ const diagramHref = nidHrefFor(db, (id, seq) => refHref(id, seq, nid));
293
+ const rendered = await Promise.all(events.map(async (e) => ({
294
+ event: e,
295
+ contentHtml: e.content
296
+ ? isSystemEvent(e.type)
297
+ ? await renderMarkdown(linkifyNarrativeRefs(systemLine({ type: e.type, content: e.content }, { agent: narrative.agent_type, fold: narrative.fold_path }), knownIds, nid), { trusted: true, diagramHref })
298
+ : await renderMarkdown(linkifyNarrativeRefs(e.content, knownIds, nid), { diagramHref })
299
+ : "",
300
+ })));
301
+ const title = `${nid} — brane conversation`;
302
+ // Every "now" in this document is the moment of export, so the activity span
303
+ // and the "Exported …" stamp below it can't disagree.
304
+ const exportedAt = Date.now();
305
+ const exportDate = formatTimestamp(exportedAt);
306
+ // Lifecycle rows are shown but not counted — "12 messages" should mean twelve
307
+ // things said, not twelve rows on the page.
308
+ const exportedMessageSeqs = events.filter((e) => !isSystemEvent(e.type)).map((e) => e.seq);
309
+ const messageCount = exportedMessageSeqs.length;
310
+ // An excerpt says so where the count goes. "3 of 47 messages" is the whole
311
+ // disclosure: it is the one line that tells a reader handed this file that
312
+ // there was more conversation than the document contains.
313
+ const countLine = selectedSeqs
314
+ ? `${messageCount} of ${totalMessages} messages`
315
+ : `${messageCount} messages`;
316
+ // The header is the narrative card, rendered statically: identity row (id ·
317
+ // fold) over the meta line (cost · /role · activity span), then the summary.
318
+ // Both lines come from the same `formatCost` / `formatActivitySpan` the app's
319
+ // card uses, so the two can't drift. The card's live status dot and
320
+ // LIVE/IDLE/DEAD badge are deliberately absent — a snapshot has no live
321
+ // status — and 📸 stands in their place.
322
+ const cost = formatCost(narrative.cost_amount, narrative.cost_unit);
323
+ const activitySpan = typeof narrative.created_at === "number"
324
+ ? formatActivitySpan(narrative.created_at, narrative.last_activity, exportedAt)
325
+ : null;
326
+ const metaBits = [
327
+ cost ? `<span class="cost">${escHtml(cost)}</span>` : "",
328
+ narrative.role ? `<span class="role">/${escHtml(narrative.role)}</span>` : "",
329
+ activitySpan ? `<span class="label">from ${escHtml(activitySpan)}</span>` : "",
330
+ ].filter(Boolean);
331
+ // The span is relative to export time, which a reader opening this months
332
+ // later can't know — so the absolute timestamps ride along as a tooltip.
333
+ const spanTitle = activitySpan && typeof narrative.created_at === "number"
334
+ ? ` title="${escHtml(`created ${formatTimestamp(narrative.created_at)}${narrative.last_activity ? ` · last activity ${formatTimestamp(narrative.last_activity)}` : ""}`)}"`
335
+ : "";
336
+ const metaLine = metaBits.length
337
+ ? `<div class="meta-line"${spanTitle}><span class="snapshot">📸</span>${metaBits.join(" ")}</div>`
338
+ : "";
339
+ const foldLine = narrative.fold_path
340
+ ? ` <span class="fold"><span class="label">in </span>${escHtml(narrative.fold_path)}</span>`
341
+ : "";
342
+ // The agent-written summary describes the whole narrative, so it can
343
+ // paraphrase exactly the messages an `except` export was asked to leave out.
344
+ // A redaction the header then undoes is worse than none; the excerpt keeps it.
345
+ const summaryHtml = narrative.summary && !excluding
346
+ ? `<div class="summary">${escHtml(narrative.summary)}</div>`
347
+ : "";
348
+ const palette = opts.palette ?? 0;
349
+ // What an excerpt skipped between two of its messages — one indexed COUNT per
350
+ // gap, whose cost is the gap rather than the conversation. A full export has
351
+ // no gaps by construction and asks nothing.
352
+ const gapBetween = (lo, hi) => hi - lo <= 1 ? 0 : countMessages(db, nid, { after: lo, before: hi });
353
+ const messagesHtml = rendered.map(({ event: e, contentHtml }, i) => {
354
+ const fromOp = e.type === "human_signal";
355
+ const foreign = e.type === "agent_signal";
356
+ // A foreign row wears its speaker's hue — the same `speakerColor` /
357
+ // `speakerBgColor` the app's bubbles use, at the same salt, so the export
358
+ // is a snapshot of the conversation as the operator saw it rather than a
359
+ // lookalike in a single flat green. Inline rather than a class because the
360
+ // hue is per speaker and the document carries no script to compute it.
361
+ const speakerStyle = foreign
362
+ ? ` style="background:${speakerBgColor(e.source || "unknown", palette)}"`
363
+ : "";
364
+ const sourceStyle = foreign && e.source
365
+ ? ` style="color:${speakerColor(e.source, palette)}"`
366
+ : "";
367
+ const system = isSystemEvent(e.type);
368
+ const time = formatTimestamp(e.time);
369
+ let bubbleClass = "agent";
370
+ if (system)
371
+ bubbleClass = "system";
372
+ else if (fromOp)
373
+ bubbleClass = "operator";
374
+ else if (foreign)
375
+ bubbleClass = "foreign";
376
+ // Attribution header. Every row gets one, in the same slot. Lifecycle events
377
+ // have no `source` — the daemon wrote them — so they name "system"
378
+ // explicitly rather than going anonymous. Operator rows say "operator"
379
+ // rather than the app's "you": an exported file is read by whoever it was
380
+ // handed to, who is generally not the operator. The channel it arrived
381
+ // over (`web`, `cli`, …) rides along as `operator/web`, since a snapshot
382
+ // is the only place that provenance survives.
383
+ //
384
+ // A foreign row's speaker is a second link: the nid points at the sibling
385
+ // `<speaker>.html`, the same way references in message bodies do, so a
386
+ // directory of exports lets the reader follow a foreign narration back to
387
+ // where it was written. Only the nid navigates — the `(nid:seq)` beside it
388
+ // stays the in-page anchor every other row has — matching the app, where
389
+ // the bubble body is for reading and copying, not for leaving the page. A
390
+ // speaker with no sibling file to point at (not a narrative this daemon
391
+ // knows) is written as plain text rather than a dead link.
392
+ const selfAnchor = (text) => `<a href="#seq-${e.seq}">${escHtml(text)}</a>`;
393
+ const sourceHtml = system
394
+ ? selfAnchor(`system (${nid}:${e.seq})`)
395
+ : fromOp
396
+ ? selfAnchor(`${e.source ? `operator/${e.source}` : "operator"} (${nid}:${e.seq})`)
397
+ : e.source
398
+ ? foreign
399
+ ? `${knownIds.has(e.source)
400
+ ? `<a href="${refHref(e.source, undefined, nid)}" class="speaker">${escHtml(e.source)}</a>`
401
+ : escHtml(e.source)} ${selfAnchor(`(${nid}:${e.seq})`)}`
402
+ : selfAnchor(`${e.source}:${e.seq}`)
403
+ : "";
404
+ const source = sourceHtml
405
+ ? `<div class="source${system ? " system-source" : ""}"${sourceStyle}>${sourceHtml}</div>`
406
+ : "";
407
+ const images = (e.payloads ?? [])
408
+ .filter(isImagePayload)
409
+ .map((path) => {
410
+ const dataUri = imageDataUris.get(path);
411
+ if (!dataUri)
412
+ return "";
413
+ return `<img src="${dataUri}" style="max-width:100%;border-radius:8px;margin-top:6px;" />`;
414
+ })
415
+ .join("\n");
416
+ const files = (e.payloads ?? [])
417
+ .filter((p) => !isImagePayload(p))
418
+ .map((path) => {
419
+ const filename = path.split("/").pop() ?? path;
420
+ return `<div class="file-attachment">${escHtml(filename)}</div>`;
421
+ })
422
+ .join("\n");
423
+ const wrapped = contentHtml ? `<div class="content">${contentHtml}</div>` : "";
424
+ // Interior gaps only. What was dropped before the first selected message or
425
+ // after the last is already stated by the header's "3 of 47" — but a gap
426
+ // *between* two shown messages would otherwise read as adjacency, which is
427
+ // the one thing an excerpt must not imply about a conversation.
428
+ const prev = selectedSeqs && i > 0 ? rendered[i - 1].event : null;
429
+ const skipped = prev ? gapBetween(prev.seq, e.seq) : 0;
430
+ const elision = skipped > 0
431
+ ? `<div class="row elision-row"><div class="elision">⋯ ${skipped} message${skipped === 1 ? "" : "s"} not shown</div></div>\n`
432
+ : "";
433
+ return `${elision}<div class="row ${bubbleClass}-row" id="seq-${e.seq}">
434
+ <div class="bubble ${bubbleClass}"${speakerStyle}>
435
+ ${source}${wrapped}${images}${files}
436
+ <a href="#seq-${e.seq}" class="time">${escHtml(time)}</a>
437
+ </div>
438
+ </div>`;
439
+ }).join("\n");
440
+ // KaTeX's stylesheet and the woff2 faces this document actually uses, as one
441
+ // inline block — so the math renders with no network, no CDN, and no script.
442
+ // Null when nothing on the page is math, which is the common case.
443
+ const katexStyle = katexStyleFor(messagesHtml) ?? "";
444
+ const cleanHtml = `<!DOCTYPE html>
445
+ <html lang="en">
446
+ <head>
447
+ <meta charset="UTF-8">
448
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
449
+ <title>${escHtml(title)}</title>
450
+ <style>
451
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
452
+ body {
453
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
454
+ background: #000; color: #e0e0e0; padding: 16px;
455
+ }
456
+ /* The narrative card, statically. Type scale, colors, and ordering track
457
+ expo/components/NarrativeMeta.tsx — id 15px/600 mono, everything else 13px
458
+ mono, so the whole block shares one baseline. */
459
+ header { padding: 16px 0; border-bottom: 1px solid #2c2c2e; margin-bottom: 16px; }
460
+ header .identity { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0; min-width: 0; }
461
+ header h1 {
462
+ font-size: 15px; color: #d8d8dd; font-weight: 600;
463
+ font-family: Menlo, monospace;
464
+ }
465
+ header .fold { margin-left: 8px; font-size: 13px; font-family: Menlo, monospace; color: #5e8a72; }
466
+ header .meta-line { margin-top: 4px; font-size: 13px; font-family: Menlo, monospace; }
467
+ header .snapshot { margin-right: 8px; }
468
+ header .cost { color: #7bb894; }
469
+ header .role { color: #d8d8dd; }
470
+ header .label { color: #888; }
471
+ header .summary { margin-top: 4px; font-size: 13px; color: #999; }
472
+ header .exported { margin-top: 8px; font-size: 12px; color: #666; }
473
+ .row { display: flex; margin-bottom: 6px; }
474
+ .agent-row { justify-content: flex-start; }
475
+ .operator-row { justify-content: flex-end; }
476
+ .foreign-row { justify-content: center; }
477
+ .system-row { justify-content: center; }
478
+ .bubble {
479
+ max-width: 85%; border-radius: 16px; padding: 10px 14px;
480
+ word-wrap: break-word; overflow-wrap: break-word;
481
+ }
482
+ .agent { background: #1c1c1e; border-bottom-left-radius: 4px; }
483
+ .operator { background: #1a4a7a; border-bottom-right-radius: 4px; }
484
+ .foreign { background: #1a2a1a; border-radius: 12px; }
485
+ /* Lifecycle events (spawn/twin/revive/bind rejection) — present but recessive,
486
+ so they read as stage directions rather than as something someone said. */
487
+ /* Only the fill and the outline differ from a normal bubble — padding, the
488
+ attribution-header / content / timestamp stack, and the type size are all
489
+ deliberately the same, so a system row reads as a message nobody spoke
490
+ rather than as a different kind of object. */
491
+ .system { background: transparent; border: 1px dashed #3a3a3c; border-radius: 12px; }
492
+ .system .content { color: #8a8a8e; }
493
+ /* The gap marker an excerpt puts between two messages that were not adjacent.
494
+ Centred and unbubbled on purpose: it is the document talking about itself,
495
+ not a row anybody wrote, and it must not be mistakable for one. */
496
+ .elision-row { justify-content: center; }
497
+ .elision { font-size: 12px; color: #6a6a6e; font-style: italic; padding: 2px 0; }
498
+ /* The fold a started line ends with (systemLine in shared/narrative-format),
499
+ in the identity row's green. Its href is a brane:// scheme no browser can
500
+ open, so it is inert here — a label, not a link. */
501
+ .system .content a[href^="${FOLD_LINK_SCHEME}"] { color: ${FOLD_COLOR}; text-decoration: none; pointer-events: none; font-family: Menlo, monospace; font-size: 13px; }
502
+ /* The daemon isn't a speaker, so its attribution sits in the dim palette
503
+ rather than the green reserved for agents and the operator. */
504
+ .source.system-source { color: #8a8a8e; }
505
+ .source { color: #4caf50; font-size: 11px; font-family: monospace; margin-bottom: 3px; }
506
+ .source a { color: inherit; text-decoration: none; }
507
+ /* The foreign speaker's nid leaves the page, so it reads as a link. */
508
+ .source a.speaker { text-decoration: underline; }
509
+ .content { font-size: 15px; line-height: 1.4; }
510
+ .content p { margin: 8px 0; }
511
+ .content hr { border: none; height: 1px; background: #444; margin: 12px 0; }
512
+ .content h1 { font-size: 26px; line-height: 1.23; font-weight: 700; margin: 16px 0 8px; color: #f0f0f0; }
513
+ .content h2 { font-size: 22px; line-height: 1.27; font-weight: 700; margin: 14px 0 6px; color: #f0f0f0; }
514
+ .content h3 { font-size: 19px; line-height: 1.32; font-weight: 700; margin: 12px 0 4px; color: #f0f0f0; }
515
+ .content h4 { font-size: 17px; line-height: 1.35; font-weight: 600; margin: 10px 0 4px; color: #e8e8e8; }
516
+ .content h5 { font-size: 16px; line-height: 1.38; font-weight: 600; margin: 8px 0 2px; color: #e8e8e8; }
517
+ .content h6 { font-size: 15px; line-height: 1.4; font-weight: 600; margin: 6px 0 2px; color: #ccc; }
518
+ .content code {
519
+ color: #f8c555; background: #1a1a1a; font-family: Menlo, monospace;
520
+ font-size: 13px; padding: 1px 4px; border-radius: 3px;
521
+ }
522
+ .content pre { background: #1a1a1a; padding: 10px; border-radius: 6px; margin: 4px 0; overflow-x: auto; font-size: 13px; line-height: 1.45; }
523
+ .content pre code { padding: 0; background: none; color: inherit; font-family: Menlo, monospace; }
524
+ .content pre.shiki { padding: 10px; border-radius: 6px; margin: 4px 0; overflow-x: auto; font-size: 13px; line-height: 1.45; font-family: Menlo, monospace; }
525
+ .content pre.shiki code { background: none; padding: 0; color: inherit; font-family: inherit; font-size: inherit; }
526
+ /* Display math is the one block that can be wider than the bubble — a long
527
+ equation scrolls rather than forcing the whole page sideways. KaTeX inherits
528
+ the CSS color property, so it picks up the bubble's text color for free.
529
+ The rules themselves are MATH_BLOCK_CSS, shared with the app so the two
530
+ scroll alike, and so an equation dissolves into the same bubble in both. */
531
+ ${MATH_BLOCK_CSS}
532
+ /* A diagram is drawn at its natural size (the SVG carries a fixed width), so a
533
+ wide one scrolls inside the bubble the way a code block does, rather than
534
+ spilling out of it. The app's DiagramView host has the same two rules. */
535
+ .content .diagram { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; margin: 6px 0; max-width: 100%; }
536
+ .content .diagram > svg { display: block; }
537
+ /* Hover or tap an arrow and it is drawn again on top with its label. The
538
+ rules are DIAGRAM_EDGE_HIGHLIGHT_CSS, shared with the app. */
539
+ ${DIAGRAM_EDGE_HIGHLIGHT_CSS}
540
+ .content a { color: #6cb6ff; text-decoration: underline; }
541
+ .content strong { color: #f0f0f0; font-weight: 600; }
542
+ .content em { font-style: italic; }
543
+ .content table { border-collapse: collapse; margin: 6px 0; width: 100%; font-size: 14px; }
544
+ .content th, .content td { border: 1px solid #3a3a3c; padding: 6px 10px; text-align: left; }
545
+ .content th { background: #2c2c2e; color: #f0f0f0; font-weight: 600; }
546
+ .content tr:nth-child(even) { background: rgba(255,255,255,0.03); }
547
+ .content ul, .content ol { margin: 4px 0; padding-left: 24px; }
548
+ .content li { margin: 2px 0; }
549
+ /* GFM task list items. micromark emits a real disabled checkbox input, which is
550
+ the right markup and the wrong look: a disabled control renders in the
551
+ browser's grey whatever accent-color says, on a page that is otherwise black.
552
+ So appearance:none takes the box back and rebuilds it at the same 13px the
553
+ app draws (expo/components/Markdown.tsx, listCheckbox), and a downloaded
554
+ narrative matches the live one.
555
+ NB: this whole stylesheet is a template literal — no backticks below. */
556
+ .content input[type="checkbox"] {
557
+ appearance: none; -webkit-appearance: none;
558
+ width: 13px; height: 13px; margin: 0 6px 0 0; vertical-align: -1px;
559
+ border: 1px solid #8a8a8e; border-radius: 3px; background: transparent;
560
+ }
561
+ .content input[type="checkbox"]:checked { background: #6cb6ff; border-color: #6cb6ff; }
562
+ .content input[type="checkbox"]:checked::after {
563
+ content: "✓"; display: block; text-align: center;
564
+ color: #111; font-size: 10px; line-height: 11px;
565
+ }
566
+ /* The checkbox stands in for the marker, as it does on GitHub. The item holds
567
+ the input directly when the list is tight and inside its paragraph when the
568
+ list is loose, so both shapes are named. */
569
+ .content li:has(> input[type="checkbox"]),
570
+ .content li:has(> p:first-child > input[type="checkbox"]:first-child) { list-style: none; }
571
+ .time { color: #666; font-size: 11px; margin-top: 4px; text-align: right; text-decoration: none; display: block; }
572
+ .file-attachment {
573
+ color: #6cf; font-size: 13px; background: rgba(255,255,255,0.1);
574
+ border-radius: 6px; padding: 6px 10px; margin-top: 6px;
575
+ }
576
+ footer { padding: 16px 0; border-top: 1px solid #2c2c2e; margin-top: 16px; text-align: center; }
577
+ footer .branding { font-size: 12px; color: #555; margin-top: 12px; }
578
+ footer .branding a { color: #7fff9f; text-decoration: underline; text-shadow: 0 0 8px rgba(100, 255, 150, 0.6), 0 0 20px rgba(100, 255, 150, 0.3); }
579
+ #save-btn {
580
+ position: fixed; top: 12px; right: 12px; z-index: 100;
581
+ background: transparent; border: none; font-size: 28px;
582
+ cursor: pointer; padding: 6px;
583
+ filter: drop-shadow(0 0 8px rgba(100, 180, 255, 0.6)) drop-shadow(0 0 20px rgba(100, 180, 255, 0.3));
584
+ transition: filter 0.2s;
585
+ }
586
+ #save-btn:hover, #save-btn:active {
587
+ filter: drop-shadow(0 0 12px rgba(100, 180, 255, 0.9)) drop-shadow(0 0 30px rgba(100, 180, 255, 0.5));
588
+ }
589
+ </style>
590
+ ${katexStyle}
591
+ </head>
592
+ <body>
593
+ <header>
594
+ <div class="identity"><h1>${escHtml(nid)}</h1>${foldLine}</div>
595
+ ${metaLine}
596
+ ${summaryHtml}
597
+ <div class="exported">Exported ${escHtml(exportDate)} · ${escHtml(countLine)}</div>
598
+ </header>
599
+ ${messagesHtml}
600
+ <footer>
601
+ <div class="branding">exported from <a href="https://brane.organelle.dev">brane</a></div>
602
+ </footer>
603
+ </body>
604
+ </html>`;
605
+ if (!opts.interactive)
606
+ return cleanHtml;
607
+ // Interactive wrapper: add a 💾 button that downloads the clean HTML.
608
+ // The clean version is embedded as base64 so the button is self-contained.
609
+ const cleanBase64 = Buffer.from(cleanHtml, "utf-8").toString("base64");
610
+ return cleanHtml.replace("</header>", `<button id="save-btn" onclick="saveHtml()">💾</button>
611
+ </header>
612
+ <script>
613
+ function saveHtml() {
614
+ var raw = atob("${cleanBase64}");
615
+ var bytes = new Uint8Array(raw.length);
616
+ for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
617
+ var blob = new Blob([new TextDecoder().decode(bytes)], { type: 'text/html' });
618
+ var url = URL.createObjectURL(blob);
619
+ var a = document.createElement('a');
620
+ a.href = url;
621
+ a.download = ${JSON.stringify(exportFileName(nid, selectedSeqs ? { seqs: exportedMessageSeqs, total: totalMessages } : null))};
622
+ document.body.appendChild(a);
623
+ a.click();
624
+ document.body.removeChild(a);
625
+ URL.revokeObjectURL(url);
626
+ }
627
+ ` + `<` + `/script>`);
628
+ }
629
+ //# sourceMappingURL=export.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=archive-types.js.map