brane 0.1.1 → 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 (348) 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 +79 -27
  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/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
@@ -0,0 +1,163 @@
1
+ # Diagram Rendering
2
+
3
+ A ` ```mermaid ` fence draws. Like code highlighting and unlike KaTeX it renders
4
+ **on the daemon** — `POST /api/diagram` for the app, a direct call for the HTML
5
+ export — because a diagram is rare and heavy where inline math is ubiquitous and
6
+ light. The Expo bundle gains nothing: `@dagrejs/dagre` (1.9 MB, 2 packages) is
7
+ the only new dependency and it never reaches the browser.
8
+
9
+ | Piece | Where | Does |
10
+ |---|---|---|
11
+ | `parseDiagram` | `src/diagram/parse.ts` | Source → IR, or a refusal with a reason and a line. Linear time — see below |
12
+ | `parseLabel` | `src/diagram/label.ts` | Label text → lines of styled runs, for both of mermaid's label grammars — see below |
13
+ | `measureNode` | `src/diagram/metrics.ts` | Text width without a DOM — Helvetica and Helvetica-Bold advances, per-block fallbacks |
14
+ | `layoutDiagram` | `src/diagram/layout.ts` | dagre, plus **face-projected attachment** (below), resolved `classDef`s, and synthesised self-loops |
15
+ | `basisPath` | `src/diagram/svg.ts` | `d3-shape`'s `curveBasis`, ported; pinned against d3's own output |
16
+ | `nidHrefFor` | `src/diagram/links.ts` | The seam: a node labelled with a real narrative becomes a link. Takes the target form, so the export points at sibling `<nid>.html` files rather than the app's `brane://` scheme |
17
+ | `renderDiagram` | `src/diagram/index.ts` | The one entry point, for both targets |
18
+ | `edgeHighlight` / `DIAGRAM_EDGE_HIGHLIGHT_CSS` | `src/diagram/svg.ts`, `src/shared/diagram-css.ts` | Hover or tap an arrow, or its label, to draw it again in a highlight color, above every other edge and label and below the boxes, which cover its ends. CSS only, so the export stays scriptless; the export inlines the rule and `DiagramView` injects the same string. Label groups paint above line groups, so a later arrow's hit stroke cannot cover an earlier arrow's label |
19
+
20
+ **The dialect is measured, not chosen.** Across every mermaid fence in the
21
+ archive (21 diagrams, 316 lines, home brane plus two absorbed ones): `flowchart`
22
+ and `graph` only, `-->` 131 and `<-->` 9, `[rect]` 125 · `(round)` 9 ·
23
+ `[(cyl)]` 5 · `{diamond}` 4 · `>asym]` 4 · `[[sub]]` 1, plus `subgraph`, `&`,
24
+ `<br/>`, `classDef`/`class`/`style` and `%%`. So that is what draws, and 20 of
25
+ the 21 do; the refusal is the one `stateDiagram-v2`, a later increment. Bump the
26
+ census when the dialect grows. Styled label text is the one part of the
27
+ dialect admitted without a count: on 2026-10-01 this daemon's 26 fences held
28
+ no markdown string, `<b>` or `font-weight` at all, but an agent on another
29
+ daemon that wanted bold tried all three in a row and got literal tags, then
30
+ literal backticks and asterisks, then nothing, with a clean receipt each time
31
+ (claude-cdouqlgt:12–:24). Absence from the census was the quiet failure, not
32
+ disuse.
33
+
34
+ Eight things are less obvious than they look:
35
+
36
+ **The dialect adds no syntax.** Every brane-specific behaviour is
37
+ *interpretation* of text that is already legal mermaid — a node labelled
38
+ `claude-0qjv4nny` is a link here and plain text on GitHub. So the same fence
39
+ stays portable, and there is no second grammar to keep in step. Only a label
40
+ that is *entirely* a reference resolves, and only to a narrative that exists,
41
+ which is the rule `markdown-nids.ts` already applies in prose. A node **cites
42
+ and does not address**: `@nid` is deliberately not linked, because
43
+ `mentions-scan` walks the rendered markdown where a fence is inert, so linking
44
+ it would show a mention that notifies nobody.
45
+
46
+ **Arrows arrive crooked because of the attachment point, not the curve.** dagre
47
+ picks an edge's terminal point by intersecting the node's box with the chord
48
+ from the node's *centre* to the first bend; that chord is diagonal, so the tilt
49
+ is baked in before any curve is fitted — 28px of lean on the LIVE loop's
50
+ `Listen → Interpret`. `projectAttachment` slides the point along its face until
51
+ it lines up under the bend, which drops mean arrival angle from 31.4° to 8.7°
52
+ **with the curve untouched**. Four curve-side fixes were built and measured
53
+ first (clamped-tangent Hermite, basis + normal stubs, basis + straight tail,
54
+ rotating the arrowhead alone); every one bought squareness by adding
55
+ inflections, and inflections are what read as busy. `curveBasis` stays exactly
56
+ what mermaid uses. `faceOffset` generalises the projection to diamonds, circles
57
+ and hexagons, whose boundary is not a box.
58
+
59
+ **A refusal is a normal answer, and needs no new failure path.** `renderDiagram`
60
+ returns `null`, and both call sites already handled that: `export.ts`'s fence
61
+ loop substitutes only non-null results, and the app's `DiagramView` falls back
62
+ to `HighlightedCode`. So an undrawn diagram is exactly the highlighted code
63
+ block a mermaid fence rendered as before this existed. `diagnoseDiagram` reports
64
+ *why* through `narrate`'s `warnings` (`diagram_unsupported`,
65
+ `diagram_parse_error`) — the same real decision the renderer takes, over fences
66
+ found by `fromMarkdown` rather than by a pattern. The first draft scanned for
67
+ them with a regex three lines under a comment forbidding exactly that, and it
68
+ disagreed with the renderers in both directions: a mermaid fence quoted inside
69
+ a longer ```` fence warned about a diagram that was never going to draw, and
70
+ one inside a blockquote drew but was never diagnosed.
71
+
72
+ **Nothing from a fence reaches the output as markup.** Labels are escaped;
73
+ `<br/>`, `<b>` and markdown strings are parsed into styled runs, which become
74
+ `<tspan>`s whose attributes `svg.ts` writes itself; `style` declarations are
75
+ filtered to seven properties with a value charset that excludes `url(`, and
76
+ `font-weight`/`font-style` to their keywords.
77
+ There is deliberately **no `<style>` element** — an inline SVG's `<style>` is
78
+ *document*-global, so one diagram's `text { font-family }` would restyle every
79
+ other diagram on the page. Theming is CSS custom properties
80
+ (`--brane-diagram-*`) **whose fallbacks are brane's dark palette**, and marker
81
+ ids are hashed per source so two diagrams on a page cannot share an arrowhead.
82
+ The fallbacks matter more than the properties: nothing defines the latter, and
83
+ the first draft shipped light-mode values that drew `#33373d` lines on the
84
+ app's `#121214` and the export's `#000`.
85
+
86
+ **Labels have two grammars, and only one of them is markdown.** In a plain
87
+ label (`A[text]`, `A["text"]`) mermaid honours `<br/>`, `<b>`, `<i>`,
88
+ `<code>`, `<u>`, `<s>` and their kin, and `**x**` is two asterisks, on GitHub
89
+ too. A **markdown string** (``A["`text`"]``) is the other: the backticks just
90
+ inside the quotes are mermaid's marker, not over-quoting, and inside one
91
+ `**bold**` and `*italic*` render, a newline is a line break, and long lines
92
+ wrap. `label.ts` parses each into runs (`{ text, bold?, italic?, code?,
93
+ strike?, underline? }`) that the IR carries beside the plain `lines` links and
94
+ search still read. A markdown string goes through `fromMarkdown`, so "bold" means
95
+ here what it means in prose. It can span source lines, so the parser joins a
96
+ statement until every ``"` `` it opened is closed, held to `MAX_LINE_LENGTH`
97
+ like a line. Wrapping is mermaid's 200px at its 16px, scaled to brane's 14px:
98
+ the em width is what decides where a line breaks, and an unscaled 200px kept
99
+ together words that GitHub splits. Bold is measured with Helvetica-Bold's own
100
+ advances, so a bold head gets the box it needs. `font-weight` and
101
+ `font-style` from `style` or `classDef` are folded into the runs in layout, so
102
+ what is measured is what is drawn. Everything that still shows as typed (`**`
103
+ in a plain label, an unknown tag, a link in a markdown string) comes back from
104
+ the same parse as a `diagram_label_literal` warning that says how to style it.
105
+ The diagram still draws. It is a note, not a refusal.
106
+
107
+ A node's markdown string may also hold a **GFM table**, which mermaid does not
108
+ draw at all ("Unsupported markdown: table"). Brane draws it, and that is
109
+ allowed for two reasons. The fence is still valid mermaid, so GitHub shows the
110
+ fallback text and nothing breaks. And the drawing is static SVG made on the
111
+ daemon, so the HTML export carries no rendering logic for it (the condition
112
+ the operator set, claude-uwaj0umf:33). A table is a `TableBlock` among the
113
+ label's lines and takes `1 + rows` line slots. Its columns share widths, which
114
+ is the one thing a stack of centred `<tspan>`s cannot express, so a node
115
+ holding one is drawn as a `<text>` per line or cell on the same baselines the
116
+ stack would have used. Header cells are bolded at parse time, so measurement
117
+ and drawing agree without either knowing which row is the header. Edge labels
118
+ and subgraph titles have no box for a grid, so there a table shows as source,
119
+ with a warning. Plain labels also decode character references (`&lt;`,
120
+ `&#x2192;`, `&rarr;`) after tags are scanned, as mermaid's HTML labels do, so
121
+ a decoded `<` is never read as a tag.
122
+
123
+ **The parser runs on the `narrate` path, so its cost is a security property.**
124
+ `diagnoseMarkdown` calls it for every narration containing a mermaid fence, no
125
+ route and no operator involved. The first draft's inline-label patterns were
126
+ `-{2,}\s+(.+?)\s+-{2,}>` — three overlapping quantifiers over whitespace,
127
+ cubic in the run length, 1.5 s on a 2,000-space line and 8× per doubling, with
128
+ `MAX_SOURCE_BYTES` no defence because it bounds *n* while the cost is n³. Label
129
+ groups now anchor both ends on a non-space and exclude their own link's
130
+ punctuation, there is a `MAX_LINE_LENGTH`, and `diagram-review.test.ts` asserts
131
+ the complexity rather than the timing. Treat any future regex here the same
132
+ way.
133
+
134
+ **A wrong diagram is worse than no diagram.** Three of the review findings on
135
+ the original PR were *silent*: `A -.-> B -.-> C` drew one edge and dropped a
136
+ node, `A --- B --> C` likewise, and an edge into a subgraph id threw inside
137
+ dagre, was caught, and fell back with no warning at all — breaking the "warns
138
+ iff the operator sees source" rule below. The parser now refuses each with a
139
+ reason. When in doubt, refuse: the reader gets source and the author gets told.
140
+
141
+ **Self-loops are never given to dagre, and bow off a face no rank edge uses.**
142
+ Dagre returns a degenerate path for `A --> A` — duplicated points that double
143
+ back and start outside the node — so a loop is synthesised as an arc, with its
144
+ label past the apex. It leaves a *cross-axis* face (right in TD/BT, bottom in
145
+ LR/RL): dagre gives every edge `minlen` 1, so edges arrive only on the
146
+ rank-facing sides, and a loop there put its label exactly where they cross —
147
+ on the Rule 30 board, 101's label read as the 110→101 edge's. Dagre is told
148
+ about the loop by a box grown on the cross axis (`loopBox`), which keeps
149
+ neighbours clear: a TD/BT diagram's height (the chat bubble's) drops against
150
+ #504's layout, and an LR/RL one's grows, since its loops now hang below the
151
+ node — the price of labels that no longer sit on rank edges. Edges on a
152
+ loop-bearing node are pinned to its rank faces, and run straight along the
153
+ rank axis from dagre's widened box to the drawn face (`rankKnot`), so a steep
154
+ edge's last leg can't cut the corner of a chip wider than the node. A bow's
155
+ drawn apex is 23/24 of its control depth; label placement depends on it. Stacked loops step outward by
156
+ each chip's extent along the bow, and an outer bow flares past every chip
157
+ inside it. `diagram-self-loop-labels.test.ts` holds each chip against every
158
+ other edge's drawn *path*, not just other chips — the check the first
159
+ version lacked. Parallel and antiparallel edges, by contrast, dagre separates
160
+ correctly on its own.
161
+
162
+ Native renders the code block: there is no DOM to put an SVG into, and the
163
+ honest options are their own change.
package/docs/doctor.md ADDED
@@ -0,0 +1,36 @@
1
+ # Doctor
2
+
3
+ `brane doctor` answers "is this install sound?" on the machine it landed on.
4
+ It prints a short table of environment checks and then runs the **shipped
5
+ test suite** — `dist/test/*.test.js` ships in the tarball on purpose — under
6
+ the same `node` that runs the CLI, streaming the reporter and exiting with
7
+ its status. The suite needs no daemon, tmux, network, or model download: the
8
+ search tests inject a stub embedder and the wiki/fold tests use temp dirs and
9
+ in-memory SQLite, so a red result is the install, not the environment.
10
+
11
+ No test may open the operator's database. `openDatabase()` throws when
12
+ `NODE_TEST_CONTEXT` is set (which `node --test` does in every test process),
13
+ so a code path that reaches it without a `db` fails its test instead of
14
+ opening `~/.brane/brane.db` and running migrations on it — from a checkout or
15
+ from `doctor` on an installed machine. Code the suite drives takes a `db`
16
+ (`getConfig(key, db)`); tests build theirs with `createTestDb()`.
17
+
18
+ Two tests read *source* rather than import code (`api-request.test.ts` lints
19
+ `expo/lib/api.ts`; `key-routing.test.ts` lints two `.tsx` files). The source
20
+ isn't in the tarball, so they `skip` with a reason when it's absent — green by
21
+ construction from an install, still enforced from a checkout. A source-reading
22
+ test added later must do the same, or `doctor` goes red on every install.
23
+
24
+ The daemon check asks two questions: is one running (and from which
25
+ checkout), and can *this shell* reach it. Outside an agent session the CLI
26
+ holds no token and reaches the daemon only over `~/.brane/daemon.sock`, so
27
+ the check fails, with the reason, when the daemon is not listening there or
28
+ `~/.brane` is not `0700` and its own (`shellReachProblem()`). A `– token
29
+ file` line appears when `~/.brane/.daemon_token` is still on disk: older
30
+ branes kept this machine's local token there, the daemon empties it at its
31
+ first start, and removing the leftover is the operator's to do, with the
32
+ `rm` line printed (`leftoverTokenFile()`; see [No local
33
+ token](token-rotation.md#no-local-token)).
34
+
35
+ `--no-suite` runs only the environment checks; `--suite-only` runs only the
36
+ tests (`brane doctor --suite-only` is the one-liner for a bug report).
@@ -0,0 +1,69 @@
1
+ # Export Scope
2
+
3
+ An export is the whole conversation, the messages the operator picked, or
4
+ everything but them. The picking is not new: the chat has kept a selection
5
+ since `re` chips existed — tap a bubble's timestamp and its seq joins
6
+ `selectedSeqs` (`expo/app/[nid].tsx`), which is what the composer attaches
7
+ to the next signal as `re`. Export reads the same set, as *"these ones"* or
8
+ as *"all but these"*.
9
+
10
+ | Piece | Where | Does |
11
+ |---|---|---|
12
+ | `MessageSelection` | `src/shared/message-selection.ts` | The selection itself, with per-seq subscriptions — see [The Selection Store](selection-store.md) |
13
+ | `formatSeqParam` / `parseSeqParam` | `src/shared/export-selection.ts` | The `seqs=12,14,19` grammar, both halves in one module; `src/test/export-selection.test.ts` |
14
+ | `SeqSelectionMode` | `src/shared/export-selection.ts` | Which side of it: `only` (the default) or `except`, on the wire as `seqs_mode` |
15
+ | `selectedCount` / `ExportScope` | `src/shared/narrative-menu.ts` | Splits the 🦠 item into **📨 Export 2 selected** and **📤 Export 2 excluded**, and tells the press which one — the list card has no selection and passes nothing |
16
+ | `ExportOptions.seqs` / `.seqsMode` | `src/export.ts` | Keeps (or drops) those events, counts the gaps, writes the disclosure |
17
+
18
+ Seven things are less obvious than they look:
19
+
20
+ **An excerpt says it is one, in the line that used to say how many.**
21
+ `Exported … · 3 of 47 messages` is the whole disclosure, and it is
22
+ load-bearing: the reader of an exported file is generally not the operator
23
+ who made it, and a 3-message document that does not mention the other 44
24
+ reads as a short conversation rather than a quotation from a long one.
25
+
26
+ **A gap between two shown messages is marked; a gap before or after them is
27
+ not.** `⋯ 4 messages not shown` sits between non-adjacent picks, because
28
+ two bubbles stacked in a transcript otherwise assert adjacency — the one
29
+ claim an excerpt must never make by accident. What was dropped from the
30
+ ends is already covered by the header's count, and marking it would put an
31
+ elision row at the top of nearly every excerpt.
32
+
33
+ **Anchors stay `#seq-N`.** Rows are not renumbered, so `nid:seq` citations
34
+ into an excerpt mean what they mean everywhere else, and a full export and
35
+ an excerpt of the same narrative agree on every link. It is also why the
36
+ selection travels as seqs rather than as an index range.
37
+
38
+ **An unreadable `seqs` exports everything rather than failing.** An export
39
+ is a read; a 400 would cost the operator their transcript over a typo in a
40
+ URL they probably did not type. Empty, absent, and junk all mean "no
41
+ selection", and `{ seqs: [] }` is byte-identical to the export brane made
42
+ before this parameter existed — pinned by `src/test/export.test.ts`.
43
+
44
+ **The mode's tolerance runs one way.** Only the exact word `except`
45
+ inverts an export; `Except`, `exclude`, `1` and junk all read as `only`.
46
+ The asymmetry is the point: a garbled `only` hands the operator a document
47
+ whose header already says how much of the conversation it holds, while a
48
+ garbled `except` would hand them the *complement* of what they asked for —
49
+ and a selection is as often "the messages nobody else should read" as it is
50
+ "the interesting ones". For the same reason the parameter is written only
51
+ when it changes something, so an ordinary excerpt's URL is unchanged.
52
+
53
+ **An excerpt's file name carries its count; a redaction leaves the summary
54
+ out.** The 💾 button saves `<nid>.html` for a full export and
55
+ `<nid>.3-of-47.<digest>.html` for either scope (`exportFileName`): the count
56
+ the header discloses, plus six hex digits naming exactly which seqs it holds.
57
+ The count alone gave `only` and `except` of a half-selection one name, and
58
+ they are the pair a redaction exists to keep apart. Like the header, the name
59
+ doesn't say which side of the line was taken. An `except` export drops
60
+ the narrative's summary, which is agent-written over the whole conversation
61
+ and can paraphrase exactly what was left out.
62
+
63
+ **The complement reads the conversation; the excerpt does not.** `seqs IN
64
+ (…)` is an index lookup per selected message, which is why exporting two
65
+ messages out of ten thousand costs two rows. Excluding two of ten thousand
66
+ exports 9,998 of them, so it reads the log and drops the picks — a full
67
+ export's cost, which is what it is. Both scopes then disclose identically:
68
+ `3 of 47` and `44 of 47` are both excerpts, and neither says which side of
69
+ the line it took.
@@ -0,0 +1,133 @@
1
+ # Files View
2
+
3
+ How the daemon serves a narrative's fold to the Files tab, what it can show, and how 🔎 searches a binary.
4
+
5
+ ## Files API
6
+
7
+ `GET /api/files?nid=<nid>&path=<rel>` browses the filesystem under a narrative's `fold_path`. The route is `browseFold()` (`src/files/browse.ts`), a pure function of (fold, path, options) so the suite drives it without HTTP. The response is discriminated on `type`:
8
+
9
+ - `{ type: "directory", path, entries: [{ name, is_dir, size, mtime, archive?, link? }], archive? }` — dotfiles hidden
10
+ - `{ type: "file", path, size, mtime, content, truncated?, binary?, highlight?, hex?, link?, notice?, archive? }`
11
+
12
+ `highlight` is a pre-tokenized Shiki payload (`{ lang, theme, bg, fg, tokens }`) so the client renders colored code without shipping a highlighter to the browser. Adding `&raw=1` streams the bytes instead of JSON, for any file: the `src` for images and PDFs, and **Download** for a file too large to show (which, before, got JSON back). `&offset=N` asks for the hex page at byte N of a binary file. A path may run through an archive (`dist/app.zip/src/main.ts`); what that means is [Files View Formats](#files-view-formats).
13
+
14
+ Path handling is traversal-confined: absolute paths are rejected, `#`/`?` fragments are stripped, the target is `resolve()`d and must equal the fold root or sit beneath it. The echoed `path` is normalized so breadcrumbs never render `fold/./blah`.
15
+
16
+ `GET /api/files/list?nid=<nid>` returns `{ files, truncated }` — a flat path list for the whole fold, feeding the client-side fuzzy finder. It does not descend into archives: that would mean opening every one in the fold to answer a keystroke.
17
+
18
+ ## Files View Formats
19
+
20
+ What the Files view can show is one of brane's quiet advantages, so it is
21
+ kept as a census, and a format that renders is pinned by a test that builds
22
+ it byte by byte (`src/test/files-browse.test.ts`, which needs no `zip` or
23
+ `tar` binary, so it runs under `brane doctor` too).
24
+
25
+ | File | Shown as | By |
26
+ |---|---|---|
27
+ | Source and text | Shiki-highlighted code with line anchors | `CodeView` |
28
+ | Markdown (`.md`, `.mdx`, and `.md.gz`) | Rendered, math and diagrams included | `MarkdownContent` |
29
+ | Images, PDFs | Inline image; pdf.js pages | `<img>`, `PdfView` |
30
+ | **Anything else** | Hex, `xxd`-style, 16 KB a page, 16 bytes a row (8 on a phone), searchable to the last byte | `HexView` |
31
+ | `.zip` and its kin (`.jar` `.war` `.aar` `.apk` `.ipa` `.whl` `.epub` `.docx` `.xlsx` `.pptx` `.odt` `.nupkg` `.vsix` `.xpi` …) | A folder | `src/files/zip.ts` |
32
+ | `.tar`, `.tar.gz`/`.tgz`, `.tar.br`, `.tar.zst` (Node ≥ 22.15) | A folder | `src/files/tar.ts` |
33
+ | `.gz`, `.br`, `.zst` | What they compress, named by what's inside | `archiveFormatFor` |
34
+
35
+ **PDFs use pdf.js's legacy build.** `PdfView` imports `public/pdf.min.mjs`
36
+ and its worker at runtime, and both come from `pdfjs-dist/legacy/build/`
37
+ (`npm run sync:pdfjs` in `expo/`, output committed). The modern `build/`
38
+ calls `Map.prototype.getOrInsertComputed` with no fallback, so on a browser
39
+ without it the first `page.render` throws and every page stays blank. The
40
+ legacy build carries the core-js polyfills, worker included, for about 60 KB.
41
+
42
+ **Hex is the floor.** A file is binary when a NUL appears in its first 8000
43
+ bytes, git's rule, and it pages rather than truncating, so a 50 MB `.so` is
44
+ browsable where a 3 MB log still gets "too large". Before, anything that was
45
+ not an image or PDF was decoded as UTF-8 and shown as noise. Pages sit where
46
+ they belong in the file, in whatever order they were loaded, with a gap row
47
+ for each run between them (`⋯ 1,228,800 bytes not loaded`, and ↓ / ↑ to
48
+ load the 16 KB on either side); `hexSegments` in `src/shared/hex-pages.ts`
49
+ lays that out. The pages live in a `HexDoc` (`expo/lib/hexDoc.ts`) rather
50
+ than the view's state, because 🔎 loads them too.
51
+
52
+ ## Searching the bytes
53
+
54
+ 🔎 on a hex file is the daemon's, not the DOM's. It used to search the text
55
+ of the pages already loaded, which missed three ways: nothing on an unloaded
56
+ page existed, a string that wrapped a row was split by the offset and hex
57
+ columns between its halves, and `cffaedfe` never matched a column that reads
58
+ `cf fa ed fe`. `GET /api/files/find` (`src/files/find.ts`) reads the bytes
59
+ themselves, through the `ByteSource` the hex page reads, so an archive member
60
+ or a `.gz` body is searched as it is shown.
61
+
62
+ A query is matched as its UTF-8 text, smart-case like 🔎 everywhere else,
63
+ and, when it is a run of hex pairs (`cf fa ed fe`, `cffaedfe`, `0xCAFE`), as
64
+ those bytes as well: `cafe` finds the word and 0xCA 0xFE both. A hit is
65
+ painted by byte range in the hex column and the gutter alike
66
+ (`expo/lib/hexFind.ts`), one range per row it spans.
67
+
68
+ **What a search returns is bounded by pages, not by the file.** Hits come
69
+ from at most the first 64 hex pages that have any (1 MB, as much as the view
70
+ will hold), and at most 5,000 of them. A one-byte query in a 2 GB binary
71
+ therefore stops after a megabyte of hits instead of reading two gigabytes
72
+ for hits nobody will visit; the status line pinned above the rows says so
73
+ and offers **Search on from 0x…**, the next window. The drum rolls over
74
+ every hit in the window and **fetches nothing while it rolls**: the page
75
+ under a hit loads when the drum comes to rest on it, and until then the
76
+ hit's gap row stands where it will be. Measured in headless Chromium: a
77
+ 30-hit roll across a 40 MB file made no page requests while rolling and one
78
+ at rest.
79
+
80
+ The other bounds are against accidents: a token holder can already type into
81
+ every tmux session.
82
+
83
+ | Bound | Why |
84
+ |---|---|
85
+ | One pass per request (`ByteSource.chunks`), never re-reading a compressed prefix | Paged requests would re-inflate a `.gz` from its start each time |
86
+ | Stops at the reach limit, and says so (`stop: "reach"`) | `chunks` throws `ReachError` where `stream()` would end quietly |
87
+ | 30 s of scanning, then `stop: "time"` and a `next` to resume from | A slow disk or a vast file doesn't hold a request open indefinitely |
88
+ | At most 2 scans daemon-wide; a third supersedes the oldest (`FindSlots`) | The likeliest third is the same reader's next keystroke |
89
+ | The app debounces 200 ms and aborts the previous request, and a closed request stops the read | Typing `NEEDLE` is one scan, not six |
90
+ | Needles of at most 256 bytes | A needle is held in every chunk's carry, and nothing a reader types is longer |
91
+
92
+ Hits are reported in offset order across chunk seams, two needles included:
93
+ a hit is reported only once every needle that could start before it has been
94
+ seen whole. That is what makes the page count a running count and `next` a
95
+ resume point that skips nothing, and `src/test/files-find.test.ts` holds it
96
+ against a naive search over 200 randomized splits.
97
+
98
+ **An archive is a folder, and nothing is extracted.** A `ByteSource`
99
+ (`src/files/bytes.ts`) is anything the view can open: a file, an archive
100
+ member, a `.gz` body. Every renderer reads through `respondWithSource`, so a
101
+ member gets highlighting, images, hex and download for free. A zip is listed
102
+ from its central directory (one read, nothing inflated) and a member inflates
103
+ alone; a tar is one pass over its headers with the data skipped, cached per
104
+ (path, size, mtime) because a compressed tar has no index. Archives do not
105
+ nest: a zip inside a zip opens as hex.
106
+
107
+ The usual archive vulnerabilities come from *extracting*, and there is no
108
+ extraction here:
109
+
110
+ - **Zip-slip, symlink and hard-link entries, overwrites, setuid bits:** not
111
+ applicable, since nothing is written. Member names are only displayed.
112
+ `normalizeMemberName` drops leading slashes and hides any name with `..`
113
+ or a NUL, counting it (`archive.skipped`, shown as a banner) rather than
114
+ drawing it somewhere it doesn't belong. Links are shown with their target
115
+ and never followed; a tar hard link resolves once, to an earlier member of
116
+ the same archive.
117
+ - **Decompression bombs:** a read decodes only as far as it asked for and
118
+ tears the pipeline down (`sliceStream`), so output is bounded by the
119
+ request, never by a size the archive claims. Opening a 64 MB-of-zeros
120
+ member decodes one 16 KB page. No read reaches past 1 GB into a
121
+ compressed stream (`MAX_STREAM_REACH`), which bounds the CPU any one
122
+ request can spend, and a tar listing stops at 1 GB decoded or 200,000
123
+ members and says it is partial.
124
+ - **Parser bugs:** the readers are TypeScript plus Node's own zlib. No
125
+ archive tool is shelled out to: `unzip` and `bsdtar` are C with CVE
126
+ histories, and bsdtar in particular auto-detects dozens of formats nobody
127
+ asked it to parse. Every zip offset is checked against the file's real
128
+ size before it is read. A malformed archive throws, and a file that isn't
129
+ what its name claims shows as the file it is.
130
+
131
+ Refused, with the reason shown in the view: encrypted zip members,
132
+ compression methods other than stored and deflate, and device or FIFO
133
+ entries.
@@ -0,0 +1,38 @@
1
+ # Link Previews
2
+
3
+ A brane URL pasted into iMessage, Slack, Discord or Telegram gets a card,
4
+ and the card is built from the HTML the URL serves — by a fetcher that
5
+ never runs the bundle. That bounds what the card can be, and the bound is
6
+ by design: the daemon's host and token ride in the fragment ([View
7
+ URLs](view-urls.md)), which no fetcher sends, and the daemon itself is
8
+ tailnet-only. So a card can carry what brane *is* and what it looks like,
9
+ never a narrative's title, summary or messages. (iMessage is the one app
10
+ that unfurls on the *sender's* device, which is on the tailnet — but a
11
+ direct-daemon link still serves the same shell, so it sees the same card.)
12
+
13
+ The card is `og:*` and `twitter:card` tags in **`expo/public/index.html`**,
14
+ the template that actually ships, with `app/+html.tsx` kept in step the way
15
+ the KaTeX and manifest links are. The picture is the 膜 — a still of the
16
+ real `Membrane` component, not a hand-ported approximation of its shader —
17
+ at `public/og/membrane.v<N>.png` (1200×630, the size every unfurler renders
18
+ as a large card), plus `public/apple-touch-icon.png` (180×180, which is
19
+ what iMessage's small card and an iOS home-screen add use; the SVG favicon
20
+ counts for neither). `expo/scripts/og-membrane/render.mjs` produces both
21
+ by mounting the component alone in a headless Chromium and screenshotting
22
+ it; each run rolls a fresh scheme, so run it until a frame pleases, then
23
+ **bump the version in the filename and in both `og:image` tags** — iMessage
24
+ caches a preview by URL until the URL changes. `src/test/link-preview.test.ts`
25
+ pins that the two heads agree, that the URL names a file `public/` ships at
26
+ the declared size, and that the name is versioned.
27
+
28
+ Two things are less obvious than they look:
29
+
30
+ **The image URL is absolute, on the shared host.** A daemon's own bundle
31
+ serves this same `index.html`, and a phone unfurling a direct-daemon link
32
+ can still fetch `https://brane.organelle.dev/og/…` — a relative path would
33
+ point at the daemon, which the fetcher may or may not reach.
34
+
35
+ **Stills only.** iMessage, Telegram, Discord and Facebook will animate an
36
+ `og:video` loop, and Slack alone offers an interactive card, through a
37
+ registered app. Neither is used: the card is a picture, by decision
38
+ (claude-hun39p6w:10).
package/docs/math.md ADDED
@@ -0,0 +1,32 @@
1
+ # Math Rendering
2
+
3
+ LaTeX in narrative markdown renders through **KaTeX**, in the app and in the HTML export alike. The two call the *same* `renderMathHtml()` in `src/shared/markdown-math.ts`, which is what makes an export a snapshot of the live view rather than a lookalike of it — the export contains no script, so all rendering happens ahead of time.
4
+
5
+ | Piece | Where | Does |
6
+ |---|---|---|
7
+ | `renderMathHtml()` | `src/shared/markdown-math.ts` | The one KaTeX call. `htmlAndMathml`; unparseable LaTeX falls back to the literal source, not KaTeX's red error markup |
8
+ | `prepareMathMarkdown()` | `src/shared/markdown-math.ts` | Pre-parse normalization, run by both sides before micromark/mdast |
9
+ | `mathHtmlExtension()`, `katexStyleFor()` | `src/math.ts` | Export only: micromark hookup, and the inlined stylesheet |
10
+ | `public/katex/` | `expo/` | The app's stylesheet + woff2, vendored by `npm run sync:katex` |
11
+ | `diagnoseMarkdown()` | `src/markdown-diagnostics.ts` | The same pipeline run to *report* rather than render — see [Rendering Warnings](rendering-warnings.md) |
12
+
13
+ **Every delimiter an LLM might reach for.** Which one a model writes is house style, not a standard, and the difference is stark: across the 42,521 events this was built against, `\(…\)` appears 1576 times and `\[…\]` 351 — all from absorbed ChatGPT history — against 578 single-dollar runs, which are mostly Claude's. A `$`-only reader would leave two thirds of the corpus as prose. `prepareMathMarkdown` normalizes the lot into the `$` forms before parsing, so one syntax reaches both renderers:
14
+
15
+ | Written | Becomes | Renders |
16
+ |---|---|---|
17
+ | `$x$` | unchanged | inline |
18
+ | `$$x$$` alone on a line | fenced `$$` | display |
19
+ | `\(x\)` | `$x$` | inline |
20
+ | `\[x\]` alone on a line | fenced `$$` | display |
21
+ | ` ```math ` fence | fenced `$$` | display |
22
+ | ` ```latex ` fence | unchanged | source listing, as intended |
23
+
24
+ Unlike `$`, the TeX pairs are unambiguous and need no guard — nothing in ordinary prose looks like them (0 false positives across all 1576). KaTeX also gets a `\*` → `*` macro: that isn't TeX, it's a model escaping an asterisk so markdown won't read emphasis and leaving the escape inside the math. It accounts for 13 of the 18 equations in the archive KaTeX would otherwise reject.
25
+
26
+ Two more things are less obvious than they look:
27
+
28
+ **`$` is mostly not a delimiter.** Narratives are full of prices, shell variables and awk fields, and `micromark-extension-math` is more permissive than Pandoc — it reads `$10k–$500k` as math over `10k–`. `prepareMathMarkdown` escapes the openers that fail a Pandoc-style guard (no space just inside a delimiter, no digit after the closer, no `${…}`, no raw `"`) before parsing, since the extension offers no veto once it has tokenized. It also promotes a one-line `$$…$$` paragraph to the fenced form, which is the only shape the extension renders in display mode. Both sides must run it or exports stop matching the app.
29
+
30
+ **Fonts don't come along by themselves.** KaTeX lays its span tree out entirely in CSS and needs its woff2 faces; without them math is a pile of overlapping glyphs. The export inlines only the families the document reaches for (~166 KB of base64 for an equation-dense narrative, nothing at all for one with no math), derived from the stylesheet so a KaTeX upgrade can't silently drop a face. The app can't inline per document, so it serves `public/katex/` — vendored the way `pdf.min.mjs` already is, because metro bundles a CSS *import* but never copies the `url(fonts/…)` it points at. The `<link>` lives in **`public/index.html`**, which is the real HTML template; `app/+html.tsx` is kept in step but is not what ships.
31
+
32
+ `src/shared/markdown-math.ts` is also the first shared module to import an npm package. Metro resolves a module's dependencies from its own directory and `src/shared` sits outside the expo project root, so `metro.config.js` maps `katex` explicitly — the next shared module with a bare import will need the same.
package/docs/mcp.md ADDED
@@ -0,0 +1,69 @@
1
+ # MCP and the LIVE Loop
2
+
3
+ The MCP bridge, how a parked `listen` wakes and retires, the LIVE loop tools, and narrative metadata.
4
+
5
+ ## MCP Architecture
6
+
7
+ The MCP server (`src/mcp/server.ts`) is a **thin stdio↔WebSocket bridge**. It has zero SQLite dependency. All tool logic (narrate, listen, whoami, revive, etc.) lives in the daemon at `src/daemon/server.ts`.
8
+
9
+ **Error handling convention**: Use `sendToolError()` for real errors (maps to MCP `isError: true`), not `sendToolResult({ error: ... })`. The bridge at `src/mcp/server.ts` translates `tool_error` → `isError: true` on the MCP response. Foreign narration to non-existent narrative IDs returns a tool error.
10
+
11
+ When `brane spawn` creates an agent session, it sets `BRANE_NARRATIVE_ID` as a tmux session environment variable (not in the MCP config file). The MCP bridge process inherits this from the tmux environment at startup. This allows multiple agents to share the same fold without conflicting MCP configs. The one exception is Copilot, which requires explicit env in its per-session config file since it doesn't inherit parent env vars into MCP subprocesses.
12
+
13
+ ### Listen Wakeup
14
+
15
+ **The caller's own words never interrupt its waiting.** `checkForEvents`
16
+ returns everything in range but reports an *arrival* only for an event whose
17
+ server-stamped `source` isn't the caller, so an agent's own narration (here or
18
+ narrated into another narrative) neither wakes its parked listen, ends its
19
+ waiting narrate, nor returns a blocking listen at once. It is still returned
20
+ alongside whatever does arrive, and a `wait: false` read returns it on its
21
+ own. #515 dropped those events from forward reads instead, and an agent that
22
+ resumed after compaction with a remembered cursor lost them silently; the
23
+ daemon can't tell that read from any other. Pinned by
24
+ `src/test/listen-own-words.test.ts`.
25
+
26
+ **A listen outlives nothing.** A parked listen (or waiting narrate) whose
27
+ socket closes is retired by the MCP close handler — `cleanup()` unregisters
28
+ its wakeups, its tool row settles, and no reply is built — and `wakeup`
29
+ refuses to answer on a socket that isn't `OPEN`. Otherwise the next event
30
+ ran its `reply`, and the mention receipt inside it marked mentions sent
31
+ (and read) into a socket that drops the send.
32
+
33
+ **Nor does it outlive its caller's patience.** Esc, or killing a
34
+ backgrounded tool task, reaches the bridge as MCP `notifications/cancelled`.
35
+ The bridge forgets the call (a late result is dropped, never sent) and sends
36
+ the daemon `tool_cancel`; `cancelMcpCall` retires the parked call as the
37
+ close handler does, and the awaiting glow goes out at once. Before, the bridge answered the notification "Method not found" (an
38
+ error with no id, which Claude Code logs as a connection error) and the
39
+ daemon never heard, so a cancelled listen glowed until the next event or the
40
+ hour-long timeout. Notifications are never answered, known or not. Pinned by
41
+ `src/test/mcp-bridge-cancel.test.ts` and `src/test/awaiting.test.ts`.
42
+
43
+ The daemon uses a `listenWaiters` map to immediately wake blocked `listen` calls when new events arrive. When a signal is sent or a narration is written, `notifyListenWaiters(nid)` fires all registered callbacks for that narrative. This replaces polling and gives near-instant delivery.
44
+
45
+ `narrate({ wait: true })` is a narrate followed by a listen in one call, so an exchange costs the agent one re-read of its context instead of two. `handleMcpNarrate` writes the narration and returns a `NarrateWaitPlan`; the dispatcher hands the plan to `startMcpListen`, which parks on the plan's cursor with the new narration filtered out wherever it landed (`omit` in `checkForEvents`) and returns listen's result plus the narration's `nid`, `seq` and `warnings`. There is no second waiting mechanism: wakeup, poller, timeout, mention receipt and shutdown flush are listen's, and the pending entry carries its `toolName` so the narrate's row is the one that settles. `narrateWaitCursor` accepts any cursor `listen` does, multi-narrative included (claude-2il811nw:31); with none, it waits from the current heads of the caller's narrative and, for a foreign narration, the target's. `before`/`tail` have nothing to wait for, and they fall back rather than refuse: the narrate posts as usual and its receipt carries `wait_ignored`, since a refusal would cost the agent the narration and a turn to resend it. On a lost daemon connection the bridge tells a pending narrate to check before re-narrating rather than to retry, since a waiting narrate has always already posted.
46
+
47
+ ## LIVE Loop Protocol
48
+
49
+ Agents communicate via cursor-based long-polling. Read `protocols/LIVE_loop.md` for full details.
50
+
51
+ Core tools available via MCP:
52
+
53
+ | Tool | Purpose |
54
+ |------|---------|
55
+ | **narrate** | Vocalize status + receive pending signals (non-blocking) |
56
+ | **listen** | Block until signals arrive (long-poll with timeout) |
57
+ | **whoami** | Get narrative ID and brane ID |
58
+ | **revive** | Restart a dead agent session |
59
+ | **narrative_search** | Semantic search across narrative history |
60
+ | **narrative_info** | Get narrative info: browse recent narratives or get details on specific ones |
61
+ | **list_peers** | List active LIVE-loop agent peers, grouped by fold |
62
+ | **list_branes** | List all branes hosted by this daemon (caller's home first) — use to expand `narrative_search` / `narrative_info` across branes |
63
+ | **list_protocols** / **read_protocol** | Access protocol documents |
64
+
65
+ ## Narrative Metadata
66
+
67
+ Narratives have persistent `summary` and `title` columns. Agents set them via the `meta` parameter on `narrate`. The daemon broadcasts metadata changes over the control WebSocket as `type: "meta"` messages. The Expo app renders the summary as a subtitle below the narrative ID.
68
+
69
+ Narratives can be **archived** via `PATCH /api/narratives/:id/archive`. Archived narratives are hidden from the default list but preserved in the database.
@@ -0,0 +1,75 @@
1
+ # Memory and Search Ranking
2
+
3
+ How narrative history is indexed for semantic search, and how hybrid search ranks what it finds.
4
+
5
+ ## Memory System
6
+
7
+ The memory system provides semantic search across narrative history:
8
+
9
+ - **Vector embeddings** via `onnxruntime-node` directly (bge-small-en-v1.5, 384-dim) — `embedder.ts` does the four steps brane used from `@huggingface/transformers` (tokenize → ONNX session → masked mean pool → L2 normalize), producing vectors identical to the pipeline it replaced (worst cosine 0.9999996), so existing `memory_vec` rows stay valid. Tokenization is `wordpiece.ts`, a WordPiece port whose ids match transformers.js exactly. The model cache layout (`~/.brane/models/<repo>/<file>`) is unchanged, so existing caches carry over.
10
+ - **FTS5 full-text search** for keyword matching
11
+ - **Hybrid search** combining both approaches — see [Search Ranking](#search-ranking)
12
+ - **Auto-indexing** of new events on first search
13
+ - CLI: `brane memory search <query>`, `brane memory log`, `brane memory index`
14
+ - MCP: `narrative_search` and `narrative_info` tools
15
+
16
+ ## Search Ranking
17
+
18
+ `searchHybrid` (`src/memory/storage.ts`) fuses **three ranked arms**, and that it
19
+ fuses *ranks* rather than *scores* is the whole design.
20
+
21
+ | Arm | Query | For |
22
+ |---|---|---|
23
+ | ALL | `prepareFtsQuery` — FTS5 implicit AND | A remembered fragment. Precise; returns nothing for a question |
24
+ | ANY | `prepareFtsAnyQuery` — content terms ORed | A question. Always answers; ranks loosely |
25
+ | vector | KNN over `memory_vec` | Rows sharing no terms with the query at all |
26
+
27
+ Each contributes `weight / (RRF_K + rank)`; the fused total is then multiplied by
28
+ how much of the query the row actually contains.
29
+
30
+ **Scores from different arms are not comparable, and pretending otherwise
31
+ inverted the ranking.** The vector arm's number is a cosine — measured across
32
+ this archive it spans 0.62–0.73 for *everything*, a band so narrow it barely
33
+ orders anything — while the lexical arms' is a squashed bm25 topping out near
34
+ 0.7 for an excellent hit. Blending those, plus a flat `+0.5` awarded for **any
35
+ single** query term appearing, produced the failure this replaced: for
36
+ `self-inflicted instance restarted the daemon four times today`, the only chunk
37
+ in 43,876 containing all eight terms came back **11th**, behind ten one-line
38
+ operator signals ("Restart* daemon", "Daemon is restarted") that each held one
39
+ word and sat near the query in embedding space. Over 60 verbatim quotations
40
+ pulled from real events, the source ranked first **50%** of the time; under rank
41
+ fusion, **90%** (top-10: 85% → 98%).
42
+
43
+ Four things are less obvious than they look:
44
+
45
+ **Coverage ranks; it does not gate.** A row is credited for the *fraction* of
46
+ query terms it holds — 1-of-8 and 8-of-8 used to score identically. But
47
+ qualifying as lexical evidence is a **count** (`MIN_TERM_HITS`), not a fraction,
48
+ because a fraction punishes long queries for being long: a narration matching 2
49
+ of 8 terms of a paraphrased question is real evidence that a 50% gate discards.
50
+
51
+ **The ANY arm exists because AND silently disables half the search.** No chunk
52
+ contains all thirteen words of a natural-language question, so the strict arm
53
+ returns zero candidates exactly when the query is hardest — leaving the vector
54
+ arm alone, which on this corpus finds a quotation's own source only a third of
55
+ the time. Stopwords are dropped from ANY (ORed, `the` matches most of the
56
+ archive) and kept in ALL, where every term is a restriction and narrows for
57
+ free.
58
+
59
+ **The length penalty is gated on lexical evidence, not on `match`.** They differ
60
+ for precisely the rows it exists to catch: a two-character chunk sitting close to
61
+ the query clears `MIN_SEMANTIC_SCORE`, gets tagged `semantic`, and would sail
62
+ past a `!match` guard. Similarity to a very short string is cheap, and is not
63
+ evidence that the string answers anything.
64
+
65
+ **The `weak` floor is relative.** A fused rank has no absolute meaning to
66
+ threshold against — it depends on how many arms returned the row and how deep
67
+ each went — so the old `score >= 0.35` constant would pass everything or nothing
68
+ as `limit` moved. Quoted phrases remain the one filter allowed to return nothing;
69
+ every other path still degrades to best-effort guesses rather than a blank page.
70
+
71
+ `src/test/search-fusion.test.ts` pins each defect as the smallest corpus that
72
+ reproduces it, with hand-chosen vectors. Deliberately **not** an aggregate
73
+ accuracy figure: the same probe read 52%, 50% and 37% across one afternoon
74
+ purely as new events were indexed, so a percentage over the live archive would be
75
+ a flaky test. A percentage says something regressed; these say which thing.