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,185 @@
1
+ /** Session affinity: pin a narrative's LLM traffic to one backend replica by
2
+ * attaching an `X-Session-Affinity: <nid>` header, so provider-side KV prefix
3
+ * caches stay warm for the life of a narrative instead of thrashing on every
4
+ * load-balanced hop. The narrative id is the right key: it survives revives,
5
+ * and brane is the only layer that sees both the id and the spawn.
6
+ *
7
+ * Only Claude Code needs brane's help, and only when it talks to Fireworks
8
+ * (an `ANTHROPIC_BASE_URL` on a `fireworks.ai` host); every other claude spawn
9
+ * launches exactly as it did before. Claude Code reads
10
+ * `ANTHROPIC_CUSTOM_HEADERS` (newline-separated `Name: Value`) from its
11
+ * environment, and the hard part is the layers between brane and that
12
+ * environment:
13
+ *
14
+ * - A settings file's `env` block is written *over* the inherited shell
15
+ * value, and settings `env` values are not `${VAR}`-expanded (verified
16
+ * against claude 2.1.278 with a SessionStart-hook probe) — so when a
17
+ * settings file defines the variable, nothing in the shell can add to it,
18
+ * and a shared fold file could only hold one literal nid. The punch-through
19
+ * is `claude --settings <path>`, which outranks every settings file
20
+ * (verified end-to-end against a header-logging listener), one file per
21
+ * narrative at `~/.brane/session-affinity/<nid>.json` carrying the winning
22
+ * file's blob plus the affinity line. The operator's own files are never
23
+ * written.
24
+ *
25
+ * - When no settings file defines it, the value Claude Code would use is
26
+ * whatever the *session's* shell inherits — from the tmux server, which
27
+ * need not share the daemon's environment. So brane must not read it from
28
+ * `process.env` and pin it into a `--settings` file: that replaced headers
29
+ * the session had and the daemon didn't (reproduced: an `X-Shell` header
30
+ * exported where tmux started vanished from the wire). Instead the header is
31
+ * appended in the tmux command's env prefix, where the session's own shell
32
+ * expands whatever it inherited.
33
+ *
34
+ * Crush is deliberately absent: it already sends `x-session-affinity` (a hash
35
+ * of its own session id) and that value wins over configured `extra_headers`.
36
+ * Other harnesses have no observed fireworks traffic.
37
+ *
38
+ * Opt out with `brane config session-affinity.enabled false`. */
39
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
40
+ import { dirname, join } from "node:path";
41
+ import { homedir } from "node:os";
42
+ import { getConfig } from "./commands/config.js";
43
+ import { getBraneDir } from "./db/schema.js";
44
+ export const SESSION_AFFINITY_HEADER = "X-Session-Affinity";
45
+ function noAffinity() {
46
+ return { args: [], env: "" };
47
+ }
48
+ /** Return `existing` (an `ANTHROPIC_CUSTOM_HEADERS` blob) with exactly one
49
+ * session-affinity line: ours, at the end. Any prior affinity line (matched
50
+ * case-insensitively on the header name, the same parse Claude Code performs)
51
+ * is replaced; every other line is preserved verbatim and in order. Pure. */
52
+ export function withSessionAffinity(existing, value) {
53
+ const lines = (existing ?? "").split(/\r?\n/).filter((line) => {
54
+ if (!line.trim())
55
+ return false;
56
+ const colon = line.indexOf(":");
57
+ if (colon === -1)
58
+ return true; // not a header line; keep as found
59
+ return line.slice(0, colon).trim().toLowerCase() !== SESSION_AFFINITY_HEADER.toLowerCase();
60
+ });
61
+ lines.push(`${SESSION_AFFINITY_HEADER}: ${value}`);
62
+ return lines.join("\n");
63
+ }
64
+ /** Shell env assignment that appends the affinity line to whatever
65
+ * `ANTHROPIC_CUSTOM_HEADERS` the session's shell inherited, with a newline
66
+ * between them only when there was something to append to. The newline is
67
+ * literal inside double quotes, which POSIX sh (tmux's `-c` shell) keeps.
68
+ * Narrative ids are `[a-z]+-[0-9a-z]+`, so the value needs no escaping. */
69
+ export function shellAffinityEnv(narrativeId) {
70
+ return `ANTHROPIC_CUSTOM_HEADERS="\${ANTHROPIC_CUSTOM_HEADERS:+\${ANTHROPIC_CUSTOM_HEADERS}\n}${SESSION_AFFINITY_HEADER}: ${narrativeId}"`;
71
+ }
72
+ /** Whether a base URL is Fireworks'. */
73
+ export function isFireworksBaseUrl(url) {
74
+ if (!url)
75
+ return false;
76
+ try {
77
+ const host = new URL(url).hostname.toLowerCase();
78
+ return host === "fireworks.ai" || host.endsWith(".fireworks.ai");
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ function readSettingsFile(path) {
85
+ let raw;
86
+ try {
87
+ raw = readFileSync(path, "utf-8");
88
+ }
89
+ catch {
90
+ return { kind: "absent" };
91
+ }
92
+ try {
93
+ const config = JSON.parse(raw);
94
+ if (config && typeof config === "object" && !Array.isArray(config)) {
95
+ return { kind: "ok", config };
96
+ }
97
+ return { kind: "broken" };
98
+ }
99
+ catch {
100
+ return { kind: "broken" };
101
+ }
102
+ }
103
+ function envFrom(read, key) {
104
+ if (read.kind !== "ok")
105
+ return undefined;
106
+ const env = read.config.env;
107
+ if (!env || typeof env !== "object" || Array.isArray(env))
108
+ return undefined;
109
+ const value = env[key];
110
+ return typeof value === "string" ? value : undefined;
111
+ }
112
+ /** Claude Code's user-level settings directory honors CLAUDE_CONFIG_DIR. */
113
+ function claudeConfigDir(homeDir) {
114
+ return process.env.CLAUDE_CONFIG_DIR ?? join(homeDir, ".claude");
115
+ }
116
+ /** Where the per-narrative `--settings` file lives. One file per nid,
117
+ * rewritten on every launch that needs it. */
118
+ export function claudeSessionAffinityPath(narrativeId, opts = {}) {
119
+ return join(opts.braneDir ?? getBraneDir(), "session-affinity", `${narrativeId}.json`);
120
+ }
121
+ /** Decide how a claude launch carries the affinity header, writing the
122
+ * per-narrative `--settings` file when that is the way.
123
+ *
124
+ * Settings files are consulted in Claude Code's precedence (local > project >
125
+ * user). `ANTHROPIC_BASE_URL` falls back to the daemon's env for the gate
126
+ * only: a URL the session inherits but the daemon doesn't is missed, which
127
+ * costs affinity and nothing else.
128
+ *
129
+ * - Not Fireworks → nothing.
130
+ * - A settings file defines `ANTHROPIC_CUSTOM_HEADERS` → `--settings` with
131
+ * that blob plus the affinity line. The file carries the operator's header
132
+ * values, credentials included, so it is kept `0600` in a `0700` directory.
133
+ * - Otherwise → the env prefix, expanded by the session's own shell. */
134
+ export function ensureClaudeSessionAffinity(foldPath, narrativeId, opts = {}) {
135
+ const home = opts.homeDir ?? homedir();
136
+ const files = [
137
+ join(foldPath, ".claude", "settings.local.json"),
138
+ join(foldPath, ".claude", "settings.json"),
139
+ join(claudeConfigDir(home), "settings.json"),
140
+ ].map(readSettingsFile);
141
+ const firstDefined = (key) => {
142
+ for (const read of files) {
143
+ const value = envFrom(read, key);
144
+ if (value !== undefined)
145
+ return value;
146
+ }
147
+ return undefined;
148
+ };
149
+ const baseUrl = firstDefined("ANTHROPIC_BASE_URL") ?? process.env.ANTHROPIC_BASE_URL;
150
+ if (!isFireworksBaseUrl(baseUrl))
151
+ return noAffinity();
152
+ const fromSettings = firstDefined("ANTHROPIC_CUSTOM_HEADERS");
153
+ if (fromSettings === undefined)
154
+ return { args: [], env: shellAffinityEnv(narrativeId) };
155
+ const desired = withSessionAffinity(fromSettings, narrativeId);
156
+ const path = claudeSessionAffinityPath(narrativeId, opts);
157
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
158
+ chmodSync(dirname(path), 0o700);
159
+ const existing = readSettingsFile(path);
160
+ if (existing.kind !== "ok" || envFrom(existing, "ANTHROPIC_CUSTOM_HEADERS") !== desired) {
161
+ writeFileSync(path, JSON.stringify({ env: { ANTHROPIC_CUSTOM_HEADERS: desired } }, null, 2) + "\n", {
162
+ mode: 0o600,
163
+ });
164
+ }
165
+ // `mode` applies only on creation; tighten a file or directory made before.
166
+ chmodSync(path, 0o600);
167
+ return { args: ["--settings", path], env: "" };
168
+ }
169
+ /** Spawn/revive entry point. Mirrors `ensureHookConfig`'s contract: best
170
+ * effort, never fatal to a launch, idempotent across the spawns and revives
171
+ * that call it. The caller puts `args` on the agent's command line and `env`
172
+ * in the tmux command's env prefix. */
173
+ export function ensureSessionAffinityConfig(agent, foldPath, narrativeId, opts = {}) {
174
+ const enabled = opts.enabled ?? getConfig("session-affinity.enabled", opts.db) !== "false";
175
+ if (!enabled || agent !== "claude")
176
+ return noAffinity();
177
+ try {
178
+ return ensureClaudeSessionAffinity(foldPath, narrativeId, opts);
179
+ }
180
+ catch (err) {
181
+ console.error(`Warning: could not write session affinity config: ${err}`);
182
+ return noAffinity();
183
+ }
184
+ }
185
+ //# sourceMappingURL=session-affinity.js.map
@@ -0,0 +1,124 @@
1
+ /** The rules a daemon API request follows, kept away from `fetch` so they can
2
+ * be tested without a browser — the same reason `view-url.ts` is window-free.
3
+ *
4
+ * These are not stylistic preferences. Each one was a failure observed on a
5
+ * 741ms-RTT satellite link (`claude-4gqgp27l:9`), where the app's four
6
+ * opening requests were aborted at exactly 10.00s having transferred zero
7
+ * bytes, each still waiting on a CORS preflight that had never returned. The
8
+ * reason the rules live here rather than inline is that **breaking them is
9
+ * invisible on a fast network**: put an `Authorization` header back on a GET
10
+ * and every LAN test still passes, while the app becomes unusable on a bad
11
+ * link. A regression that only reproduces at 35,000 feet needs a unit test.
12
+ */
13
+ /** Content types the CORS spec safelists. Anything else on a request makes it
14
+ * "not simple" and forces a preflight round trip before the request may be
15
+ * sent at all. `application/json` is famously absent. */
16
+ const SAFELISTED_CONTENT_TYPES = [
17
+ "application/x-www-form-urlencoded",
18
+ "multipart/form-data",
19
+ "text/plain",
20
+ ];
21
+ /** Header names a request may carry and still be CORS-simple. */
22
+ const SAFELISTED_HEADERS = new Set([
23
+ "accept",
24
+ "accept-language",
25
+ "content-language",
26
+ "content-type", // only with a safelisted value — checked separately
27
+ "range",
28
+ ]);
29
+ const SIMPLE_METHODS = new Set(["GET", "HEAD", "POST"]);
30
+ /** Whether the browser must send a preflight `OPTIONS` and wait for its answer
31
+ * before this cross-origin request may begin.
32
+ *
33
+ * This is the whole cost model. A preflight is not "some overhead": it is a
34
+ * full round trip — and, on a cold connection, its own TCP and TLS handshake
35
+ * — that happens *before* the request you wanted exists on the wire. At LAN
36
+ * latency it is unmeasurable. At 741ms it is the difference between an app
37
+ * that loads and one that does not. */
38
+ export function requiresPreflight(method, headers = {}) {
39
+ if (!SIMPLE_METHODS.has(method.toUpperCase()))
40
+ return true;
41
+ for (const [name, value] of Object.entries(headers)) {
42
+ const lower = name.toLowerCase();
43
+ if (!SAFELISTED_HEADERS.has(lower))
44
+ return true;
45
+ if (lower === "content-type") {
46
+ const essence = value.split(";")[0].trim().toLowerCase();
47
+ if (!SAFELISTED_CONTENT_TYPES.includes(essence))
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ /** No headers, so a request stays CORS-simple. The emptiness is the point;
54
+ * see `requiresPreflight`. */
55
+ export const NO_HEADERS = {};
56
+ /** For requests that carry a body. This preflights, and that is a considered
57
+ * choice rather than an oversight: the alternative is labelling JSON as
58
+ * `text/plain` to slip past CORS, and a Content-Type that lies is a trap for
59
+ * whoever reads the code next. Bodied requests are also the rare ones — the
60
+ * app reads far more than it writes — and the daemon's `Access-Control-Max-Age`
61
+ * makes the preflight they do need a daily cost rather than a per-request one. */
62
+ export const JSON_HEADERS = {
63
+ "Content-Type": "application/json",
64
+ };
65
+ /** Put the bearer token in the query string instead of an `Authorization`
66
+ * header. The daemon has always accepted it there (`src/daemon/server.ts`,
67
+ * checked immediately after the `Bearer` header), and it is what keeps a
68
+ * bodiless request simple.
69
+ *
70
+ * On the token being in a URL: it arrived in one. The meld URL that opens the
71
+ * app carries `?token=`, so it is already in the address bar and in history,
72
+ * and these requests address the operator's own daemon — there is no third
73
+ * party to leak a Referer to. This moves the token nowhere it has not been. */
74
+ export function withToken(path, token) {
75
+ const [base, hash] = splitHash(path);
76
+ const sep = base.includes("?") ? "&" : "?";
77
+ return `${base}${sep}token=${encodeURIComponent(token)}${hash}`;
78
+ }
79
+ /** A fragment is not sent to the server, so a token appended after one would
80
+ * authenticate nothing. Split it off and put it back at the end. */
81
+ function splitHash(path) {
82
+ const i = path.indexOf("#");
83
+ return i === -1 ? [path, ""] : [path.slice(0, i), path.slice(i)];
84
+ }
85
+ /** How long a request may take before it is abandoned.
86
+ *
87
+ * Was 10s, which is generous on a LAN and unreachable on a satellite link: at
88
+ * 741ms RTT roughly 0.75s of TCP handshake and 1.5s of TLS elapse before a
89
+ * request byte moves, and TCP needs about ten round trips to open its window.
90
+ * 60s costs a healthy network nothing — a fast request still returns fast,
91
+ * since this is a ceiling and not a delay. */
92
+ export const DEFAULT_TIMEOUT_MS = 60_000;
93
+ export const MAX_ATTEMPTS = 3;
94
+ const RETRY_BASE_MS = 500;
95
+ /** Only replay requests that cannot have had an effect.
96
+ *
97
+ * A timed-out POST is genuinely ambiguous: the daemon may have received it,
98
+ * spawned the agent, and simply been too slow to say so. Retrying that
99
+ * silently spawns a second one. Reporting the error is the lesser harm, and
100
+ * it is the operator's call to make. */
101
+ export function isRetryableMethod(method) {
102
+ const m = method.toUpperCase();
103
+ return m === "GET" || m === "HEAD";
104
+ }
105
+ /** Retry transport failures only — cases where no response ever arrived.
106
+ *
107
+ * An HTTP error is an answer, not a failure to communicate: a 404 is a fact
108
+ * that will not change on a second ask, and a 500 may have had effects. Only
109
+ * a request that never landed is safe to send again. */
110
+ export function isRetryableError(err) {
111
+ if (typeof DOMException !== "undefined" && err instanceof DOMException) {
112
+ return err.name === "AbortError";
113
+ }
114
+ if (err instanceof Error && err.name === "AbortError")
115
+ return true;
116
+ // `fetch` reports network failure as a bare TypeError.
117
+ return err instanceof TypeError;
118
+ }
119
+ /** Exponential backoff. A link that just dropped a request is often still
120
+ * congested; retrying instantly mostly adds to the pile. */
121
+ export function retryDelayMs(attempt) {
122
+ return RETRY_BASE_MS * 2 ** attempt;
123
+ }
124
+ //# sourceMappingURL=api-request.js.map
@@ -0,0 +1,114 @@
1
+ // Block elements (U+2580–U+259F), painted instead of typeset.
2
+ //
3
+ // A real terminal draws `▀ ▄ █` edge to edge in the cell. The mirror draws
4
+ // them as text in a line box pinned at `line-height: 1.4`, and a font's block
5
+ // glyph spans only its own ascent + descent — about 14px of a 16.8px row. So
6
+ // every row of block art is striped with a dark gap, which is what turned
7
+ // `brane meld`'s QR code into something that looked like a barcode and
8
+ // scanned badly. The same gap runs through progress bars and htop meters.
9
+ //
10
+ // The fix is the one xterm.js calls "custom glyphs": wrap each block
11
+ // character in a span exactly one cell wide and one line box tall, and paint
12
+ // it as a background in `currentColor`. The glyph itself stays in the DOM,
13
+ // hidden with `-webkit-text-fill-color: transparent` rather than `color`
14
+ // (which would take `currentColor` with it), so selection and copy still
15
+ // yield the real characters.
16
+ //
17
+ // Every paint is ONE full-tile image with hard stops — a linear gradient for
18
+ // a bar, a conic one for quadrants — never a sized, positioned layer. A 16.8px
19
+ // row puts cell edges on fractional pixels, and a layer sized to the cell
20
+ // anti-aliases its own edges there, so two lit cells meeting across a row
21
+ // each half-cover the boundary pixel and leave a faint hairline between them
22
+ // (measured in headless Chromium at 1× and 3×: visible on every row of the
23
+ // QR). A full-tile image's edges are the box's, which the browser snaps.
24
+ //
25
+ // Pure and window-free so `src/test/block-glyphs.test.ts` can pin the table.
26
+ const C = "currentColor";
27
+ const T = "transparent";
28
+ /** A bar: lit for `fraction` of the cell, measured from the side `to` points away from. */
29
+ function bar(to, fraction) {
30
+ const p = `${+(fraction * 100).toFixed(4)}%`;
31
+ return `linear-gradient(to ${to},${C} ${p},${T} ${p})`;
32
+ }
33
+ /**
34
+ * Quadrants by conic sweep from 12 o'clock, clockwise: upper-right, then
35
+ * lower-right, lower-left, upper-left.
36
+ */
37
+ function quadrants(ur, lr, ll, ul) {
38
+ const q = (lit) => (lit ? C : T);
39
+ return (`conic-gradient(at 50% 50%,${q(ur)} 0 25%,${q(lr)} 0 50%,` +
40
+ `${q(ll)} 0 75%,${q(ul)} 0)`);
41
+ }
42
+ /** The background each block element paints, keyed by codepoint. */
43
+ const PAINT = {
44
+ 0x2580: bar("bottom", 1 / 2), // ▀ upper half
45
+ 0x2581: bar("top", 1 / 8), // ▁ lower eighths …
46
+ 0x2582: bar("top", 2 / 8), // ▂
47
+ 0x2583: bar("top", 3 / 8), // ▃
48
+ 0x2584: bar("top", 4 / 8), // ▄
49
+ 0x2585: bar("top", 5 / 8), // ▅
50
+ 0x2586: bar("top", 6 / 8), // ▆
51
+ 0x2587: bar("top", 7 / 8), // ▇
52
+ 0x2588: C, // █ full
53
+ 0x2589: bar("right", 7 / 8), // ▉ left eighths …
54
+ 0x258a: bar("right", 6 / 8), // ▊
55
+ 0x258b: bar("right", 5 / 8), // ▋
56
+ 0x258c: bar("right", 4 / 8), // ▌
57
+ 0x258d: bar("right", 3 / 8), // ▍
58
+ 0x258e: bar("right", 2 / 8), // ▎
59
+ 0x258f: bar("right", 1 / 8), // ▏
60
+ 0x2590: bar("left", 1 / 2), // ▐ right half
61
+ // ░ ▒ ▓ — a flat tint rather than a dither, which fills the cell the same way.
62
+ 0x2591: `color-mix(in srgb,${C} 25%,${T})`,
63
+ 0x2592: `color-mix(in srgb,${C} 50%,${T})`,
64
+ 0x2593: `color-mix(in srgb,${C} 75%,${T})`,
65
+ 0x2594: bar("bottom", 1 / 8), // ▔ upper eighth
66
+ 0x2595: bar("left", 1 / 8), // ▕ right eighth
67
+ 0x2596: quadrants(false, false, true, false), // ▖
68
+ 0x2597: quadrants(false, true, false, false), // ▗
69
+ 0x2598: quadrants(false, false, false, true), // ▘
70
+ 0x2599: quadrants(false, true, true, true), // ▙
71
+ 0x259a: quadrants(false, true, false, true), // ▚
72
+ 0x259b: quadrants(true, false, true, true), // ▛
73
+ 0x259c: quadrants(true, true, false, true), // ▜
74
+ 0x259d: quadrants(true, false, false, false), // ▝
75
+ 0x259e: quadrants(true, false, true, false), // ▞
76
+ 0x259f: quadrants(true, true, true, false), // ▟
77
+ };
78
+ /** Every block element, U+2580–U+259F. */
79
+ export const BLOCK_GLYPH_RE = /[▀-▟]/g;
80
+ const HAS_BLOCK_GLYPH = /[▀-▟]/;
81
+ /** The class a block glyph's span carries: `tb-2580` for `▀`. */
82
+ export function blockGlyphClass(cp) {
83
+ return `tb-${cp.toString(16)}`;
84
+ }
85
+ /**
86
+ * Wrap each block element in `html` in its painted span. Lines without one
87
+ * — nearly all of them — cost a single regex test and come back unchanged.
88
+ *
89
+ * Safe on ansi_up output: block characters only ever appear there as text,
90
+ * never inside a tag or attribute.
91
+ */
92
+ export function paintBlockGlyphs(html) {
93
+ if (!HAS_BLOCK_GLYPH.test(html))
94
+ return html;
95
+ return html.replace(BLOCK_GLYPH_RE, (ch) => `<span class="tb ${blockGlyphClass(ch.charCodeAt(0))}">${ch}</span>`);
96
+ }
97
+ /**
98
+ * The stylesheet behind `paintBlockGlyphs`, injected once per pane.
99
+ * `lineHeight` is the pane's unitless line-height, so `height: <n>em` is
100
+ * exactly the line box the rest of the mirror's row math divides by.
101
+ */
102
+ export function blockGlyphStylesheet(lineHeight) {
103
+ const rules = [
104
+ // `width: 1ch` pins the cell to the font's advance even if a glyph falls
105
+ // back to a wider face; the glyph is invisible, so clipping it costs nothing.
106
+ `.tb{display:inline-block;width:1ch;height:${lineHeight}em;overflow:hidden;` +
107
+ `vertical-align:top;-webkit-text-fill-color:transparent}`,
108
+ ];
109
+ for (const [cp, paint] of Object.entries(PAINT)) {
110
+ rules.push(`.${blockGlyphClass(+cp)}{background:${paint}}`);
111
+ }
112
+ return rules.join("\n");
113
+ }
114
+ //# sourceMappingURL=block-glyphs.js.map
@@ -0,0 +1,17 @@
1
+ /** The rule that makes a diagram's edge highlight layer visible.
2
+ *
3
+ * `svg.ts` draws each edge a second time, on top, hidden by an `opacity`
4
+ * attribute (see `edgeHighlight` there). Hover an arrow or its label, or tap
5
+ * one (the group takes focus), and this reveals it. The SVG itself can't
6
+ * carry the rule: an inline SVG's `<style>` is document-global. So the
7
+ * export writes this string into its stylesheet and the app injects the same
8
+ * string, the arrangement `MATH_BLOCK_CSS` has, so the two cannot drift.
9
+ *
10
+ * Any CSS rule outranks a presentation attribute, which is what lets one
11
+ * generic selector reveal any edge of any diagram. */
12
+ export const DIAGRAM_EDGE_HIGHLIGHT_CSS = `
13
+ .brane-diagram .brane-diagram-edge-hl { cursor: pointer; outline: none; }
14
+ .brane-diagram .brane-diagram-edge-hl:hover,
15
+ .brane-diagram .brane-diagram-edge-hl:focus { opacity: 1; }
16
+ `;
17
+ //# sourceMappingURL=diagram-css.js.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The `seqs` export parameter — the chat's own message selection, on the wire.
3
+ *
4
+ * Both halves live here for the reason `view-url.ts` gives: a parameter whose
5
+ * writer and reader sit in different modules drifts. The app formats it
6
+ * (`expo/lib/api.ts`), the daemon parses it (`src/daemon/server.ts`), and
7
+ * `src/test/export-selection.test.ts` holds the two to the same grammar.
8
+ *
9
+ * The grammar is deliberately the dullest thing that works: ascending decimal
10
+ * seqs joined by commas, `12,14,19`. Not a range syntax — the selection is a
11
+ * set of taps, not an interval, and `12-19` would claim messages the operator
12
+ * never picked.
13
+ *
14
+ * `seqs_mode` says which side of the selection to export — the picks, or
15
+ * everything but them (see `SeqSelectionMode` below). It lives here rather
16
+ * than beside the route for the same reason `seqs` does: the two are read
17
+ * together or not at all.
18
+ */
19
+ /** Selected seqs → the query-string value, or null when there is no selection
20
+ * to send. Sorted ascending and deduped, so the same selection always
21
+ * produces the same URL whatever order it was tapped in. */
22
+ export function formatSeqParam(seqs) {
23
+ if (!seqs)
24
+ return null;
25
+ const clean = [...new Set(seqs)]
26
+ .filter((n) => Number.isInteger(n) && n > 0)
27
+ .sort((a, b) => a - b);
28
+ return clean.length > 0 ? clean.join(",") : null;
29
+ }
30
+ /** The query-string value → seqs. Anything unreadable is *no* selection, which
31
+ * exports the whole conversation: an export is a read, and a malformed
32
+ * parameter should hand the operator their transcript rather than a 400. */
33
+ export function parseSeqParam(raw) {
34
+ if (!raw)
35
+ return [];
36
+ const out = new Set();
37
+ for (const part of raw.split(",")) {
38
+ const trimmed = part.trim();
39
+ if (!trimmed)
40
+ continue;
41
+ // Plain decimal digits and nothing else. `parseInt("12abc")` is 12, and
42
+ // `Number` is no stricter than it looks — `1e2` is 100, `0x10` is 16, `+4`
43
+ // is 4 — and each would silently export a message nobody asked for. The app
44
+ // only ever writes `formatSeqParam`'s digits, so this costs it nothing.
45
+ if (!/^\d+$/.test(trimmed))
46
+ continue;
47
+ const n = Number(trimmed);
48
+ if (Number.isSafeInteger(n) && n > 0)
49
+ out.add(n);
50
+ }
51
+ return [...out].sort((a, b) => a - b);
52
+ }
53
+ /** The mode → its query-string value, or null when there is nothing to say.
54
+ * `only` is the default the daemon already assumes, so it is left off the URL
55
+ * entirely: a plain excerpt's link is byte-identical to the one brane built
56
+ * before an inverse existed. */
57
+ export function formatSeqModeParam(mode) {
58
+ return mode === "except" ? "except" : null;
59
+ }
60
+ /** The query-string value → the mode. Only the exact word `except` inverts an
61
+ * export; absent, misspelled, or junk all mean `only`.
62
+ *
63
+ * Asymmetric on purpose, and in the same direction as `parseSeqParam`'s
64
+ * tolerance: an unreadable parameter must never hand the operator the
65
+ * *complement* of the document they asked for. A garbled `only` costs them
66
+ * nothing they can't see; a garbled `except` would silently export the
67
+ * messages they were excluding. */
68
+ export function parseSeqModeParam(raw) {
69
+ return raw?.trim() === "except" ? "except" : "only";
70
+ }
71
+ /** The file name an export saves under. A full export is `<nid>.html`, which is
72
+ * also what sibling exports link to. An excerpt carries the count its header
73
+ * discloses plus a short digest of exactly which seqs it holds —
74
+ * `<nid>.3-of-47.9f2c1a.html` — so two different documents never share a
75
+ * name. The count alone was not enough: `only` and `except` of a selection
76
+ * that is half the conversation are both `2-of-4`, and that pair is the one a
77
+ * redaction exists to keep apart. Browsers tend to uniquify a clashing
78
+ * download rather than overwrite it, but `(1)` says nothing about which file
79
+ * is the redacted one.
80
+ *
81
+ * The count and a digest, not the mode, and for the header's reason: `only`
82
+ * and `except` disclose identically, and a name reading "redacted" would tell
83
+ * the reader which side of the line was taken — that something was
84
+ * deliberately withheld — when the header is careful not to. */
85
+ export function exportFileName(nid, excerpt) {
86
+ if (!excerpt)
87
+ return `${nid}.html`;
88
+ return `${nid}.${excerpt.seqs.length}-of-${excerpt.total}.${seqDigest(excerpt.seqs)}.html`;
89
+ }
90
+ /** Six hex digits naming a set of seqs: FNV-1a over the sorted list. Not a
91
+ * security property — it only has to tell two excerpts of one narrative
92
+ * apart — and dependency-free so this module stays importable anywhere. */
93
+ export function seqDigest(seqs) {
94
+ let h = 0x811c9dc5;
95
+ for (const ch of [...seqs].sort((a, b) => a - b).join(",")) {
96
+ h ^= ch.charCodeAt(0);
97
+ h = Math.imul(h, 0x01000193) >>> 0;
98
+ }
99
+ return (h >>> 8).toString(16).padStart(6, "0");
100
+ }
101
+ //# sourceMappingURL=export-selection.js.map
@@ -0,0 +1,110 @@
1
+ /** Find-in-file for the Files tab: the decisions, without the DOM.
2
+ *
3
+ * The web hook (`expo/lib/findInFile.ts`) flattens a file body's text nodes
4
+ * into one string, asks this module where the query occurs, and maps each
5
+ * hit back onto the nodes it spans as a `Range`. Everything that is a rule
6
+ * rather than a DOM call lives here, so `src/test/find-in-text.test.ts` can
7
+ * pin it without a browser — smart-case, the `:N` line jump, which hit is
8
+ * current after the query changes, and the offset → node mapping that has
9
+ * to be exactly right for a highlight to land on the characters it names. */
10
+ /** Hits past this are not collected: a one-letter query in a large file would
11
+ * otherwise build tens of thousands of Ranges for a counter nobody reads. */
12
+ export const MAX_FIND_MATCHES = 5000;
13
+ /** Smart-case, as in vim and ripgrep: an all-lowercase query matches any case,
14
+ * and a single uppercase letter makes the whole query exact. */
15
+ export function isCaseSensitive(query) {
16
+ return query !== query.toLowerCase();
17
+ }
18
+ /** `:120` → 120. Anything else — `:`, `:0`, `: 12`, `12` — is not a jump and
19
+ * is searched for literally. */
20
+ export function parseLineJump(query) {
21
+ const m = /^:(\d+)$/.exec(query.trim());
22
+ if (!m)
23
+ return null;
24
+ const n = Number(m[1]);
25
+ return n > 0 ? n : null;
26
+ }
27
+ /** Every non-overlapping occurrence of `query` in `text`, in order. The query
28
+ * is literal (no regex, no fuzziness) and is not trimmed: a trailing space is
29
+ * how you ask for the word and not the prefix. */
30
+ export function findMatches(text, query, limit = MAX_FIND_MATCHES) {
31
+ const matches = [];
32
+ if (!query)
33
+ return { matches, capped: false };
34
+ const sensitive = isCaseSensitive(query);
35
+ const hay = sensitive ? text : text.toLowerCase();
36
+ const needle = sensitive ? query : query.toLowerCase();
37
+ // toLowerCase can change a string's length (İ → i̇), which would shift every
38
+ // offset after it. Fall back to the exact-case search rather than highlight
39
+ // the wrong characters.
40
+ const [h, n] = hay.length === text.length ? [hay, needle] : [text, query];
41
+ let from = 0;
42
+ while (from <= h.length - n.length) {
43
+ const at = h.indexOf(n, from);
44
+ if (at < 0)
45
+ break;
46
+ if (matches.length >= limit)
47
+ return { matches, capped: true };
48
+ matches.push({ start: at, end: at + n.length });
49
+ from = at + n.length;
50
+ }
51
+ return { matches, capped: false };
52
+ }
53
+ /** The index of the first hit starting at or after `offset`, wrapping to 0 when
54
+ * every hit is before it; -1 when there are none. This is how the current hit
55
+ * stays put while the query is typed: each keystroke re-anchors on where the
56
+ * last current hit started, so `use` → `useM` → `useMemo` walks forward
57
+ * through the file instead of snapping back to its top. */
58
+ export function firstAtOrAfter(matches, offset) {
59
+ if (matches.length === 0)
60
+ return -1;
61
+ let lo = 0;
62
+ let hi = matches.length;
63
+ while (lo < hi) {
64
+ const mid = (lo + hi) >> 1;
65
+ if (matches[mid].start < offset)
66
+ lo = mid + 1;
67
+ else
68
+ hi = mid;
69
+ }
70
+ return lo < matches.length ? lo : 0;
71
+ }
72
+ /** Next (`+1`) or previous (`-1`) hit, wrapping at both ends. */
73
+ export function stepMatch(current, count, dir) {
74
+ if (count <= 0)
75
+ return -1;
76
+ if (current < 0)
77
+ return dir > 0 ? 0 : count - 1;
78
+ return (current + dir + count) % count;
79
+ }
80
+ /** Map a span of the flattened text onto the nodes it covers, or null when an
81
+ * end falls in a separator (which `findMatches` can only produce if the query
82
+ * itself contains the separator character). `slots` must be in ascending,
83
+ * non-overlapping order, which is how a tree walk produces them. */
84
+ export function locateSpan(slots, span) {
85
+ const start = locatePoint(slots, span.start, false);
86
+ const end = locatePoint(slots, span.end, true);
87
+ return start && end ? { start, end } : null;
88
+ }
89
+ /** The slot containing `pos`. A position on the boundary between two slots
90
+ * belongs to the later one as a start and to the earlier one as an end, so a
91
+ * Range never begins at the very end of a node or finishes at the very start
92
+ * of the next — both would be empty-looking edges on the highlight. */
93
+ function locatePoint(slots, pos, isEnd) {
94
+ let lo = 0;
95
+ let hi = slots.length - 1;
96
+ while (lo <= hi) {
97
+ const mid = (lo + hi) >> 1;
98
+ const s = slots[mid];
99
+ const lower = s.start;
100
+ const upper = s.start + s.length;
101
+ if (isEnd ? pos <= lower : pos < lower)
102
+ hi = mid - 1;
103
+ else if (isEnd ? pos > upper : pos >= upper)
104
+ lo = mid + 1;
105
+ else
106
+ return { node: mid, offset: pos - s.start };
107
+ }
108
+ return null;
109
+ }
110
+ //# sourceMappingURL=find-in-text.js.map