brane 0.1.1 → 0.3.1
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.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +360 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1101 -0
- package/dist/commands/token.js +154 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6353 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +643 -0
- package/dist/db/tokens.js +624 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +84 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +216 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +56 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +299 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +143 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +150 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +128 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +646 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +627 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +104 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +53 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- 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,26 @@
|
|
|
1
|
+
/** An `Origin` header as a Devices list shows it: the host, port included
|
|
2
|
+
* when it has one, without the scheme. `https://main.brane.organelle.dev`
|
|
3
|
+
* reads "via main.brane.organelle.dev"; a daemon's own bundle reads "via
|
|
4
|
+
* mac.tail1234.ts.net:47443". Anything that is not a URL is shown as it
|
|
5
|
+
* came (already stripped of control characters by the store). */
|
|
6
|
+
export function originHost(origin) {
|
|
7
|
+
try {
|
|
8
|
+
return new URL(origin).host || origin;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return origin;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** A host as `app_domain` or `--app` may give it: a bare host, or a URL whose
|
|
15
|
+
* host is taken (`https://main.brane.organelle.dev/` reads as the host), with
|
|
16
|
+
* a port kept if there is one, lower-cased. Every reader of `app_domain`
|
|
17
|
+
* goes through this — `brane meld`, `GET /api/status`, the app — so a value
|
|
18
|
+
* stored in URL form by `brane config` builds the same link everywhere,
|
|
19
|
+
* rather than a QR that works beside an Isolate link that reads
|
|
20
|
+
* `https://https://…`. Empty in, empty out. */
|
|
21
|
+
export function cleanAppHost(value) {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(trimmed);
|
|
24
|
+
return (m ? m[1] : trimmed.replace(/[/?#].*$/, "")).toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=app-origin.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
|