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
package/docs/absorb.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Absorb
|
|
2
|
+
|
|
3
|
+
`brane absorb` imports conversation history from external LLM providers. Input is an **extracted export directory** (not a ZIP). `--fold` is required.
|
|
4
|
+
|
|
5
|
+
The import runs **in the daemon**: `brane absorb` POSTs to `/api/absorb` and prints what comes back. `src/absorb/run.ts` holds the orchestration the CLI used to do in-process (`runAbsorb`), and the route streams its `AbsorbEvent`s as NDJSON — `info` lines for the pre-flight report, throttled `progress` lines the CLI renders as its `\r` counter, and a final `summary`; a failure after the stream has opened ends it with an `error` line, while anything the operator has to fix (unknown provider, missing directory, no identity, a fold the daemon would refuse) is refused as a plain 400/403 before the stream opens (`AbsorbInputError`, `FoldRegistrationError`) — a dry run checks the fold too, so it cannot pass a case the real run would fail. A successful import kicks off a memory indexing pass, not awaited, so search works right after it. The CLI resolves relative paths against its own cwd before sending, since the daemon's cwd means nothing to the operator; `~/…` is left for the daemon to expand. The organelle importer yields to the event loop between narratives so the daemon keeps answering — and so the progress lines actually reach the client mid-run.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
brane absorb chatgpt <export-dir> --fold <fold-path> --dry-run # preview
|
|
9
|
+
brane absorb chatgpt <export-dir> --fold <fold-path> # import
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The absorb pipeline:
|
|
13
|
+
1. Scans directory for conversation shards (`conversations-NNN.json`) + `user.json`
|
|
14
|
+
2. Builds a file index mapping asset pointer IDs to local files
|
|
15
|
+
3. Stream-parses each shard (one conversation at a time, low memory)
|
|
16
|
+
4. Resolves image asset pointers → copies files to `~/.brane/payloads/`
|
|
17
|
+
5. Extracts audio transcription text alongside string content parts
|
|
18
|
+
6. Inserts conversations as archived narratives with `role: "absorbed"`
|
|
19
|
+
|
|
20
|
+
`--dry-run` exercises all parsing and asset resolution without writing to the database.
|
|
21
|
+
|
|
22
|
+
## Local sources
|
|
23
|
+
|
|
24
|
+
The export providers above read a directory the operator asked a vendor
|
|
25
|
+
for. A **local source** reads a session store an agent harness keeps on this
|
|
26
|
+
machine, in place, and can be read again tomorrow. `brane absorb claude` is
|
|
27
|
+
the first: `~/.claude/projects/<cwd>/<session>.jsonl`, the engineering
|
|
28
|
+
half of a Claude user's history, already on the disk of anyone who has run
|
|
29
|
+
`claude` once. It is the launch demo — one command and every Claude Code
|
|
30
|
+
session ever run is searchable — and the reason `brane absorb survey`
|
|
31
|
+
exists: a new user's first step is to be told what is there. A bare
|
|
32
|
+
`brane absorb` prints usage, which names it.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
brane absorb # usage, naming the providers and `survey`
|
|
36
|
+
brane absorb survey # what this machine has, what an import would do
|
|
37
|
+
brane absorb claude --dry-run # the import, planned
|
|
38
|
+
brane absorb claude # the import
|
|
39
|
+
brane absorb claude --fold ~/dev/app # only sessions run in that directory
|
|
40
|
+
brane absorb claude --since 2w # only sessions active recently
|
|
41
|
+
brane absorb claude --session 3f1a # one session, by id prefix
|
|
42
|
+
brane absorb claude ~/backup/.claude # a copied store instead of ~/.claude
|
|
43
|
+
brane absorb claude --rebuild # re-derive absorbed sessions nobody has written into
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Piece | Where | Does |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `LocalSource` | `src/absorb/types.ts` | The seam: `locate` (is the store here), `identity`, `survey` (one cheap row per session — a stat and a head read, never message content), `extract` (the parse) |
|
|
49
|
+
| `claudeCodeSource` | `src/absorb/claude-code.ts` | Survey and parser for Claude Code's JSONL |
|
|
50
|
+
| `surveyAbsorbSources()` | `src/absorb/sources.ts` | The inventory behind `brane absorb survey` and `GET /api/absorb/survey`; also the `PROBES` — stores brane recognises but cannot read yet (codex, goose, copilot, gemini, cline, amp, Claude Desktop agent-mode), reported as *found, no extractor* so the operator learns what is there either way. The list is the tools brane spawns plus Claude Desktop, because those formats have been looked at, not because a source needs spawn support: an importer for a tool brane cannot run is welcome, the probes just aren't speculative (claude-xiy9e8wq:16, :17) |
|
|
51
|
+
| `classifySessions()` | `src/absorb/sources.ts` | Each session as `managed` / absorbed-`unchanged` / absorbed-changed / `new`, shared by survey and import so the two cannot disagree. "Unchanged" means same size and mtime *and* read by a parser as new as this one |
|
|
52
|
+
| `runLocalAbsorb()` | `src/absorb/run.ts` | The import: survey → select → classify → parse only what is new or changed |
|
|
53
|
+
| `createAbsorbContext()` / `absorbOne()` | `src/absorb/index.ts` | The per-conversation *plan* and the write that follows it exactly; the ChatGPT stream feeds the same pair |
|
|
54
|
+
|
|
55
|
+
Four things are less obvious than they look:
|
|
56
|
+
|
|
57
|
+
**Idempotent per message, not per conversation.** The count-based append
|
|
58
|
+
the ChatGPT path uses ("more messages than events → append the tail")
|
|
59
|
+
cannot survive Claude's `--fork-session`, which copies a predecessor's
|
|
60
|
+
records — ids included — into the successor's file. `absorb_messages` keys
|
|
61
|
+
every written message by the provider's own id (`uuid`); a key already held
|
|
62
|
+
is never written again, wherever it is read from. A successor file therefore
|
|
63
|
+
lands as **only its new messages, the first of them `re` the predecessor's
|
|
64
|
+
last** — a fork reads as a continuation, the same shape the twin protocol
|
|
65
|
+
gives a live fork. A file that repeats another with nothing after it is not a
|
|
66
|
+
narrative and is skipped. Sessions are absorbed oldest first so the
|
|
67
|
+
predecessor's keys are always there before the successor asks (the cost
|
|
68
|
+
backfill's ordering rule, for the same reason).
|
|
69
|
+
|
|
70
|
+
**`--brane` is a destination, and destinations are independent.** Both
|
|
71
|
+
ledgers and the absorbed-narrative lookup are keyed by brane as well as
|
|
72
|
+
provider, so the same store imported into two branes is two complete
|
|
73
|
+
copies, each with its own idempotent re-runs. The first cut keyed on
|
|
74
|
+
provider alone, and a second brane saw every file as "unchanged" and read
|
|
75
|
+
nothing (claude-xiy9e8wq:38); the provider-only rows were carried over by a
|
|
76
|
+
migration that keeps the old tables as `_v1`. The survey answers for the
|
|
77
|
+
brane a plain `brane absorb <source>` would target, the one derived from
|
|
78
|
+
the account.
|
|
79
|
+
|
|
80
|
+
**Sessions brane already owns are not imported** (claude-xiy9e8wq:13). A
|
|
81
|
+
brane-spawned agent's session file sits in the same store as everyone else's.
|
|
82
|
+
`classifySessions` marks a session `managed` when a live (non-absorbed)
|
|
83
|
+
narrative of that agent type holds its id in `agent_session_id` or
|
|
84
|
+
`session_ids`, and the import skips it and says so. Beneath that, the append
|
|
85
|
+
path matches only `role = 'absorbed'` narratives, so nothing an import reads
|
|
86
|
+
can ever be written into a live narrative's log. The agent-type filter is
|
|
87
|
+
load-bearing, not an oversight: an ordinary `claude` run in a fold with a
|
|
88
|
+
`.mcp.json` still bridges in, as an `external` narrative holding the session
|
|
89
|
+
id, and it is deliberately *not* managed. That narrative is a participant
|
|
90
|
+
that narrates almost nothing; the transcript is the only copy of the
|
|
91
|
+
conversation, so skipping it would make the conversation unsearchable rather
|
|
92
|
+
than deduplicated. The two coexist — the participant live in the home brane,
|
|
93
|
+
the record absorbed into the account's.
|
|
94
|
+
|
|
95
|
+
**Tool calls go to `tool_uses`, not into the text** (claude-xlolbv6l:65). A
|
|
96
|
+
`tool_use` block becomes a row with `source: "absorb"` through the same
|
|
97
|
+
`recordToolUse` the hooks use, keyed on the block's `toolu_…` id; the
|
|
98
|
+
`tool_result` that follows supplies output, `failed` on `is_error`, and a
|
|
99
|
+
duration from the two records' timestamps. `recordToolUse` grew an optional
|
|
100
|
+
`afterSeq` for this: a hook's row belongs at the narrative's head because the
|
|
101
|
+
call is happening now, an absorbed row belongs beside the message that made
|
|
102
|
+
it. `thinking` blocks are not imported, and reasoning is not something
|
|
103
|
+
absorb promises for any source: some tools withhold it, brane has nowhere
|
|
104
|
+
to show it, it is far more text than a reader wants, and it reads as
|
|
105
|
+
authoritative while being the part most likely to be wrong
|
|
106
|
+
(claude-xiy9e8wq:20, :22). A narrative still never records itself: for a brane-managed session the whole
|
|
107
|
+
file is skipped, so no `narrate`/`listen` row is ever written from a
|
|
108
|
+
transcript.
|
|
109
|
+
|
|
110
|
+
**A user turn is not always the operator's.** Claude Code writes its own
|
|
111
|
+
text into `user` records, each kind in a tag, and read as prose the tags
|
|
112
|
+
show through (claude-xiy9e8wq:29). Measured over one machine's store, by
|
|
113
|
+
text block: 2,100 `<task-notification>` (a background task finished), 398
|
|
114
|
+
`[Request interrupted by user]`, 17 `<command-name>` slash commands with
|
|
115
|
+
their `<local-command-stdout>`, and 9 `<bash-input>`/`<bash-stdout>` pairs
|
|
116
|
+
from `!` commands. `classifyUserBlock` gives each the rendering its meaning
|
|
117
|
+
calls for, per block, so typed text beside an injected block keeps its own
|
|
118
|
+
shape: a `!` command becomes `$ cmd` in a `sh` fence and its output a plain
|
|
119
|
+
fence (stderr labelled), a slash command a code span, an interrupt an italic
|
|
120
|
+
marker. A task notification is the completion of a call the agent made, not
|
|
121
|
+
something the operator said, so it becomes a `tool_uses` row of its own
|
|
122
|
+
(`name: task-notification`, keyed on the record uuid since one task can
|
|
123
|
+
notify twice, `parent_id` the call's `toolu_…` id when the harness names it)
|
|
124
|
+
and never enters the chat. Fences are sized past the longest backtick run
|
|
125
|
+
inside them, so a captured code block cannot break out.
|
|
126
|
+
|
|
127
|
+
**A message sent mid-turn is an attachment, and the attachment is the
|
|
128
|
+
receipt** (claude-jdfccmvh:10). What the operator types while the agent is
|
|
129
|
+
working is never written as a `user` record. The harness queues it
|
|
130
|
+
(`queue-operation`: enqueue, then dequeue or remove), and when the running
|
|
131
|
+
turn takes it in, writes an `attachment` of type `queued_command` at the
|
|
132
|
+
point the agent read it — after the tool result it was waiting on, in the
|
|
133
|
+
`parentUuid` chain — stamped with the time it was typed. Reading only `user`
|
|
134
|
+
records dropped every such message, and left the record full of confident
|
|
135
|
+
answers to questions it did not contain. `queuedTurnOf` reads the attachment
|
|
136
|
+
and nothing else: `commandMode: "prompt"` is the operator's message,
|
|
137
|
+
`"task-notification"` is a background task finishing by the same route and
|
|
138
|
+
goes to `tool_uses` like its `user`-record twin, and an unknown mode is left
|
|
139
|
+
out rather than guessed at. The queue-operation records are no second
|
|
140
|
+
signal — older harness versions write `remove` with no reason for a
|
|
141
|
+
delivery, and task notifications carry no `origin` — and none is needed: a
|
|
142
|
+
message the operator cancelled (popped back into the editor) or that was
|
|
143
|
+
never delivered has no attachment, so it stays out of the record as it
|
|
144
|
+
stayed out of the conversation (claude-jdfccmvh:8). Measured on one
|
|
145
|
+
machine's store: 450 attachments, 103 `prompt` and 347 `task-notification`;
|
|
146
|
+
of 113 queued messages that never went through, none had one.
|
|
147
|
+
|
|
148
|
+
**A pasted image is a payload** (claude-7irbudpw:52). Claude Code writes a
|
|
149
|
+
pasted image into the `user` record itself, base64 in an `image` block
|
|
150
|
+
beside its `[Image #N]` text; an image that arrives mid-turn is a
|
|
151
|
+
`turnCompanion` record written the same way, with `isMeta` set and no text.
|
|
152
|
+
The parser carries both as `attachments` on the message, the inline
|
|
153
|
+
counterpart of the ChatGPT path's `assetPointers`, and the write decodes
|
|
154
|
+
them into `~/.brane/payloads/<nid>/<time>_<seq>_<j>.<ext>` with the
|
|
155
|
+
extension from the block's `media_type`, so the chat shows them inline
|
|
156
|
+
through the payload route with no change on that side. A record with an
|
|
157
|
+
image and no words becomes a message reading `(attachment)`, the placeholder
|
|
158
|
+
the web app writes for an image-only signal, rather than falling to the
|
|
159
|
+
empty-body guard; an `isMeta` record still contributes no text, only its
|
|
160
|
+
images. Per-message idempotence covers the files: a message written once has
|
|
161
|
+
its payloads written once. `createAbsorbContext` takes a `payloadsDir`
|
|
162
|
+
override so the suite never writes under `~/.brane`. Not yet a payload: an
|
|
163
|
+
image inside a `tool_result` (a `Read` of a `.png`), because `tool_uses` has
|
|
164
|
+
no payload column; today that base64 lands in `output` under the capture cap.
|
|
165
|
+
|
|
166
|
+
**A parser improvement reaches old narratives late, not out of order.**
|
|
167
|
+
Events are append-only, so a record type the parser learns to read (#460's
|
|
168
|
+
slash commands, the mid-turn messages above) has no key in `absorb_messages`
|
|
169
|
+
and lands at the narrative's tail on the next ordinary re-run — after
|
|
170
|
+
messages it preceded. `plan()` notices: a new message that belongs before
|
|
171
|
+
something the narrative already holds is written `re` the held message it
|
|
172
|
+
follows in the conversation (`lateFor`). It reads as late, it links to its
|
|
173
|
+
place, and every `nid:seq` anyone has cited still says what it said. For
|
|
174
|
+
the re-run to happen at all, `absorb_files` records which parser read each
|
|
175
|
+
file (`LocalSource.parserVersion`): a finished session's file never changes
|
|
176
|
+
again, so on (size, mtime) alone the improvement would reach it only through
|
|
177
|
+
`--rebuild`. A file last read by an older parser is not "unchanged", and the
|
|
178
|
+
next ordinary run reads it once more; the survey counts such files as their
|
|
179
|
+
own bucket ("to re-read (newer parser)"), so a parser upgrade does not read
|
|
180
|
+
as every absorbed file having changed. Bump the version when the parser
|
|
181
|
+
learns to read something it used to skip. That is the default way a parser
|
|
182
|
+
change reaches history; `--rebuild` is not. (Run over one machine's store
|
|
183
|
+
for the mid-turn change: every absorbed session re-read, 0 of 11,812
|
|
184
|
+
existing events altered, one recovered message landing `re` the narration it
|
|
185
|
+
interrupted.)
|
|
186
|
+
|
|
187
|
+
**`--rebuild` is the one deleting operation, and it deletes only a
|
|
188
|
+
rendering.** It empties each selected narrative this brane has absorbed —
|
|
189
|
+
events, tool rows, ledger rows, memory-index rows and cursor — and imports
|
|
190
|
+
its file again: same narrative id and `created_at`, but **seqs from 1**, so
|
|
191
|
+
the narrative still resolves and a `nid:seq` citation into it may now land
|
|
192
|
+
on a different message; title refreshed; payload files left where they are
|
|
193
|
+
and rewritten by name with the same bytes. It is allowed under the golden
|
|
194
|
+
rule because the flag is the operator's explicit instruction and what is
|
|
195
|
+
discarded is derivable from a file that is demonstrably still on disk (only
|
|
196
|
+
survey candidates reach it). That holds only while the narrative is nothing
|
|
197
|
+
but that rendering. An absorbed narrative can still be written into — a
|
|
198
|
+
signal from the app, a foreign narration, a payload attached live — and
|
|
199
|
+
none of that is in any file. So a narrative holding an event with no
|
|
200
|
+
`absorb_messages` row (`liveContributions`) is **locked in**: `--rebuild`
|
|
201
|
+
reads it as an ordinary re-run, which only appends, and reports it as "Not
|
|
202
|
+
rebuilt" (claude-jdfccmvh:13). Talking in an absorbed narrative should be
|
|
203
|
+
uncommon anyway — a conversation that wants to continue belongs in a
|
|
204
|
+
`/twin`, which reads the record and leaves it alone (claude-jdfccmvh:15).
|
|
205
|
+
The guard is in the SQL (`resetAbsorbedNarrative`): only `role =
|
|
206
|
+
'absorbed'`, only this brane, only the narrative bound to that session id,
|
|
207
|
+
and only one holding nothing but what an import wrote; a live narrative is
|
|
208
|
+
never a candidate. Composes with `--fold`, `--since`, `--session` and
|
|
209
|
+
`--dry-run`. `memory_content_df` is left alone; `brane memory index
|
|
210
|
+
--rebuild` recomputes it. An ordinary re-run also refreshes a narrative's
|
|
211
|
+
title from a grown file now, since the harness retitles as it goes.
|
|
212
|
+
|
|
213
|
+
**No fold is registered.** Each session knows the directory it ran in, and
|
|
214
|
+
that becomes the narrative's `fold_path`. Registering it too would call
|
|
215
|
+
`registerFold` with the foreign brane id and *rebind* the operator's own
|
|
216
|
+
`~/dev/x` to the absorbed brane, changing what `brane spawn` does there. So
|
|
217
|
+
for a local source `--fold` is a **filter** ("sessions from this directory"),
|
|
218
|
+
never a destination, and the survey reports directories as a count.
|
|
219
|
+
|
|
220
|
+
What the survey promises a new user, and how it keeps it: nothing is read
|
|
221
|
+
but a stat and the first 64 KB of each file (for `cwd` and the start time),
|
|
222
|
+
nothing is imported until a source is named, and an import makes no network
|
|
223
|
+
call — `src/absorb/` imports node builtins, better-sqlite3 and stream-json,
|
|
224
|
+
and nothing else. The identity that names the brane comes from
|
|
225
|
+
`~/.claude.json`'s `oauthAccount` (the account uuid, so one person's sessions
|
|
226
|
+
from two machines share a brane), falling back to the OS user.
|
|
227
|
+
|
|
228
|
+
Not yet a source, and each is one `LocalSource` away: Claude Desktop's
|
|
229
|
+
agent-mode `audit.jsonl` (stream-json records, plaintext, tool lineage
|
|
230
|
+
included), a claude.ai data export, and the codex/goose/copilot/cline stores
|
|
231
|
+
whose readers `src/agents.ts` already has for resume and cost.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Cost Ledger
|
|
2
|
+
|
|
3
|
+
Each narrative card shows a cumulative cost (`narratives.cost_amount` /
|
|
4
|
+
`cost_unit`), sampled every 20 s for MCP-connected narratives from the
|
|
5
|
+
harness's own usage log — a Claude JSONL, goose's SQLite, a codex rollout.
|
|
6
|
+
Those logs belong to the harness, which may purge them, and `--fork-session`
|
|
7
|
+
copies a predecessor's messages (ids included) into the successor's file. Both
|
|
8
|
+
facts bear on the same question: *who gets credited for a message, and does
|
|
9
|
+
the answer survive the file going away?*
|
|
10
|
+
|
|
11
|
+
`usage_ledger` is the answer. The sampler writes what it read into the ledger
|
|
12
|
+
and reads the narrative's total back **from the ledger**, never from the
|
|
13
|
+
transcripts directly:
|
|
14
|
+
|
|
15
|
+
| row kind | key | written by | rule |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| entry | `<agent>:<message id>` | adapters with `readUsageEntries` (claude) | `INSERT OR IGNORE` — the first narrative to insert a key keeps the credit |
|
|
18
|
+
| session high-water | `session:<agent>:<session id>` | every other adapter, via its `readUsage` | `amount = MAX(old, new)` |
|
|
19
|
+
|
|
20
|
+
The primary key is the dedup. A message fork-copied into a later session, or
|
|
21
|
+
into another narrative, is a no-op on insert — and so is re-reading it after
|
|
22
|
+
the file that originally held it has been purged, which is what the older
|
|
23
|
+
"per-session totals that never decrease" idea got wrong: credit that is
|
|
24
|
+
recomputed from whichever files survive *moves*, and a moved credit plus a
|
|
25
|
+
frozen total is a double count. High-water rows are right for the harnesses
|
|
26
|
+
that never share history between sessions (codex's counter restarts at zero on
|
|
27
|
+
resume), because there is nothing there to mis-credit.
|
|
28
|
+
|
|
29
|
+
Rows are never deleted, and never re-priced implicitly, so between
|
|
30
|
+
re-pricings a ledger sum is monotonic by construction. Entry rows carry the
|
|
31
|
+
`model` and token counts that priced them — so a deliberate re-pricing is
|
|
32
|
+
possible as an explicit operation (see [Re-pricing](#re-pricing)), which is
|
|
33
|
+
precisely what a purged transcript would have made impossible. `cost_amount` caches it, under one transition rule: a column
|
|
34
|
+
value above the ledger sum is kept, since it was filled pre-ledger from
|
|
35
|
+
transcripts that may already be gone. Id-less `<synthetic>` records key on
|
|
36
|
+
their file position — unique and stable for an append-only transcript.
|
|
37
|
+
Writes are skipped for a session whose transcript signature hasn't changed,
|
|
38
|
+
so an idle session still costs one `stat` per tick.
|
|
39
|
+
|
|
40
|
+
**A session can change under a running process.** Claude's id is assigned
|
|
41
|
+
at launch (`--session-id`), but `/clear`, or `/resume` from inside a session,
|
|
42
|
+
moves the same process to a new transcript under an id Claude Code mints
|
|
43
|
+
itself. The bridge read `CLAUDE_CODE_SESSION_ID` once at startup and `/clear`
|
|
44
|
+
doesn't restart MCP servers, so nothing reported it: one narrative's card sat
|
|
45
|
+
at $5.52 while $26.97 went unread, and revive would have resumed the
|
|
46
|
+
conversation from before the `/clear`. Each live tick therefore runs
|
|
47
|
+
`maybeFollowLiveSession`, which asks the adapter's `liveSessionId` and, on a
|
|
48
|
+
change, retires the old id into `session_ids` and moves the pointer. Claude's
|
|
49
|
+
answer comes from `~/.claude/sessions/<pid>.json` for the pane's own pid
|
|
50
|
+
(`src/claude-live-session.ts`), accepted only if its `pid`, `tmux` session,
|
|
51
|
+
`cwd` and `startedAt` (no earlier than the latest spawn or revive) all match.
|
|
52
|
+
It is a pull from the one process whose record it is, deliberately not a hook
|
|
53
|
+
payload or bridge report: everything in the pane inherits the narrative's bind
|
|
54
|
+
secret, including a nested `claude -p`, and a subagent or reconnecting bridge
|
|
55
|
+
must have no way to move the pointer. A narrative whose pane is already gone
|
|
56
|
+
is not followed; its missed session reaches the ledger only if it is added to
|
|
57
|
+
`session_ids` and backfilled.
|
|
58
|
+
|
|
59
|
+
The 20 s sampler covers MCP-connected narratives plus a 120 s **linger** after
|
|
60
|
+
a narrative's last bridge closes — a session's final turns are written to the
|
|
61
|
+
transcript *after* MCP teardown, and the linger's few extra ticks are what
|
|
62
|
+
catch them. The linger set is in-memory, so a disconnect that straddles a
|
|
63
|
+
daemon restart is missed; that case — and every session that ended before the
|
|
64
|
+
ledger existed — reaches the ledger through **`brane cost backfill`**
|
|
65
|
+
(`POST /api/cost/backfill`). It runs in the daemon, like spawn and revive,
|
|
66
|
+
because the readers resolve transcripts against the daemon's HOME. It walks
|
|
67
|
+
narratives **oldest first**, which is what makes the credit assignment right:
|
|
68
|
+
an original must insert its messages before the twin that forked from them.
|
|
69
|
+
It is synchronous on the daemon's only thread — a cold pass over this
|
|
70
|
+
fold's transcripts is a few seconds — which is fine for a command the
|
|
71
|
+
operator runs and the reason it must never be wired to startup. It does
|
|
72
|
+
not run session-id discovery — fold+time matching against years of
|
|
73
|
+
history could adopt a transcript that was never the narrative's — so only
|
|
74
|
+
already-bound sessions are read. `--dry-run` reports the same per-narrative
|
|
75
|
+
lines (`readable/bound sessions`, rows and amount it would add, column before
|
|
76
|
+
→ after) without writing; a second real run reports 0 changed.
|
|
77
|
+
|
|
78
|
+
## Re-pricing
|
|
79
|
+
|
|
80
|
+
`src/cost/pricing.ts` is a substring-keyed table, first match wins, and a
|
|
81
|
+
price change reaches the ledger only through **`brane cost reprice`**
|
|
82
|
+
(`POST /api/cost/reprice`, `src/cost/reprice.ts`). It re-prices every USD
|
|
83
|
+
entry row that has a model and token counts under the current table,
|
|
84
|
+
**moves each affected narrative's `cost_amount` by the same delta**, and
|
|
85
|
+
broadcasts the new totals. `--dry-run` computes the identical result and
|
|
86
|
+
writes nothing. The operator runs it; it is never wired to startup.
|
|
87
|
+
|
|
88
|
+
| Piece | Where | Does |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `DEFAULT_PRICES` | `src/cost/pricing.ts` | The table. A specific key (`fable-5-1`, `opus-5-5`, `sonnet-5`) sits above its family key, and an entry names its own `cacheRead` when it isn't 0.1× input. Rates last checked against the published pricing page on 2026-09-23 |
|
|
91
|
+
| `pricing.json` | `~/.brane/` | Operator overrides, matched **before** the defaults. Appended after them, as they once were, an override for `opus-5-5` could never beat `opus` |
|
|
92
|
+
| `LEGACY_PRICES_V1` | `src/cost/pricing.ts` | The table before 2026-09-23, frozen. Never edit it: it describes rows that already exist |
|
|
93
|
+
| `recoverCacheWrite1h` | `src/cost/reprice.ts` | The 5-minute / 1-hour split of an old row's `cache_write` |
|
|
94
|
+
| `cache_write_1h`, `speed` | `usage_ledger` | Recorded on every new row, so later reprices never have to infer |
|
|
95
|
+
|
|
96
|
+
Three things are less obvious than they look:
|
|
97
|
+
|
|
98
|
+
**Old rows lost the cache split, and the amount gives it back.** Rows
|
|
99
|
+
written before `cache_write_1h` existed hold 5-minute and 1-hour writes as
|
|
100
|
+
one count, and those bill at 1.25× and 2× input. But each amount was
|
|
101
|
+
computed by a known table from exactly those tokens, so pricing every write
|
|
102
|
+
as 5-minute undershoots it by `0.75 × input × (1-hour tokens)`. A recovered
|
|
103
|
+
split is accepted only if it is a whole number of tokens in `[0,
|
|
104
|
+
cache_write]` that reproduces the recorded amount. Anything else (a row
|
|
105
|
+
priced under an override, say) is reported as unrecoverable and left as
|
|
106
|
+
recorded. The first reprice writes the split it recovers. On this machine's
|
|
107
|
+
ledger, all 24,075 priced rows recovered.
|
|
108
|
+
|
|
109
|
+
**The card total moves by the correction; it is not reset to the ledger
|
|
110
|
+
sum.** A `cost_amount` above its ledger sum holds pre-ledger cost from
|
|
111
|
+
transcripts that may be gone, which is why the sampler keeps it. A reprice
|
|
112
|
+
that reset the column would discard that. One that only lowered ledger rows
|
|
113
|
+
would change nothing on the card, because the column already sits above the
|
|
114
|
+
new sum.
|
|
115
|
+
|
|
116
|
+
**Fast mode is a multiplier, not a table.** `usage.speed === "fast"` bills
|
|
117
|
+
every category at 2× the model's standard rate, with the cache multipliers
|
|
118
|
+
on top ($8/$40 on Opus 5.5, $10/$50 on Opus 5 and 4.8). The table can't
|
|
119
|
+
express `inference_geo: "us"` (1.1×) or web search ($10 per 1,000). Neither
|
|
120
|
+
appears in a transcript here yet.
|
|
121
|
+
|
|
122
|
+
The first run corrected what three mispriced models had recorded: Fable 5.1
|
|
123
|
+
cache reads at 4× the published rate, Opus 5.5 at the general Opus rate,
|
|
124
|
+
and Sonnet 5 at Sonnet 4.x's. On a copy of the operator's database that was
|
|
125
|
+
4,906 rows over 70 narratives, $4,576.26 → $3,852.50.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Daemon
|
|
2
|
+
|
|
3
|
+
The daemon serves HTTP API + WebSocket endpoints + the Expo web app as static files.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
node dist/cli.js daemon start # Background (detached)
|
|
7
|
+
node dist/cli.js daemon start --foreground
|
|
8
|
+
node dist/cli.js daemon restart # Preserves port across restarts
|
|
9
|
+
node dist/cli.js daemon status # Shows pid, port, and the socket this machine's CLI uses
|
|
10
|
+
node dist/cli.js daemon logs -f # Tail logs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## HTTP API Endpoints
|
|
14
|
+
|
|
15
|
+
All endpoints require `Authorization: Bearer <token>` (or `?token=`), except
|
|
16
|
+
a request over `daemon.sock`, which presents none and acts as this account.
|
|
17
|
+
|
|
18
|
+
**Agent tokens reach less.** A request made with an agent's token is refused
|
|
19
|
+
(403) unless its route is listed in `AGENT_ROUTES`
|
|
20
|
+
(`src/daemon/agent-scope.ts`), and unless it comes over `daemon.sock` or
|
|
21
|
+
loopback; WebSocket upgrades too. A new route is therefore closed to agents
|
|
22
|
+
until someone lists it. An agent also may not revive or prompt a
|
|
23
|
+
`utility` session (`POST /api/run`). See [Agent tokens](token-rotation.md#agent-tokens).
|
|
24
|
+
|
|
25
|
+
| Method | Path | Purpose |
|
|
26
|
+
|--------|------|---------|
|
|
27
|
+
| `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`, `app_domain`: the shared app's host, where a daemon's own bundle hops out to on Isolate — see [View URLs](view-urls.md)) |
|
|
28
|
+
| `GET` | `/api/narratives?archived=1` | List narratives (excludes archived by default) |
|
|
29
|
+
| `GET` | `/api/narratives/recent?since=&search=&status=&fold=&limit=&archived=1&brane_ids=` | Recently active narratives — the browse half of the `narrative_info` MCP tool; every brane unless `brane_ids` scopes it |
|
|
30
|
+
| `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative |
|
|
31
|
+
| `GET` | `/api/narratives/:id/events?after=N&before=S&tail=T` | Fetch events with cursor-based query |
|
|
32
|
+
| `POST` | `/api/narratives/:id/signal` | Send operator signal (supports payloads). `source` is stamped from the token (`web` for a device, `local` for this account); a `source` in the body is ignored, and an agent's token is refused (`src/daemon/signal-source.ts`) |
|
|
33
|
+
| `GET` | `/api/narratives/:id/export?format=html[&palette=N][&seqs=A,B,C][&seqs_mode=except]` | Conversation as a standalone HTML document; `palette` is the device's 🎨 Recolor salt, so foreign speakers wear the hues the operator sees, `seqs` is the chat's message selection, and `seqs_mode=except` exports its complement — see [Export Scope](export-scope.md) |
|
|
34
|
+
| `GET` | `/api/payloads/:nid/:filename` | Serve stored payload files (immutable, cached) |
|
|
35
|
+
| `GET` | `/api/branes` | List branes with narrative counts, home flag, and `cost: [{ unit, amount, metered }]` — per-unit running totals over every narrative, archived included |
|
|
36
|
+
| `GET` | `/api/folds?archived=1` | List registered fold directories (includes brane_id; archived hidden unless asked) |
|
|
37
|
+
| `POST` | `/api/folds` | Register a fold (`{ path, brane_id? }`) — idempotent; see [Folds API](#folds-api) for who may register what |
|
|
38
|
+
| `PATCH` | `/api/folds/archive` | Soft-hide/unhide a fold (`{ path, archived }`) — never deletes the row |
|
|
39
|
+
| `POST` | `/api/spawn` | Spawn agent (`{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }`) — used by both the web app and `brane spawn` |
|
|
40
|
+
| `GET` | `/api/spawn-history` | Recent distinct `launch_args`, most-recent first (spawn dialog quick-fill) |
|
|
41
|
+
| `POST` | `/api/run` | Run non-agent command (`{ command, fold_path, terminal_size? }`) |
|
|
42
|
+
| `GET` | `/api/run-history` | Commands utility sessions have run before (by basename), most-recent first — the spawn dialog's Utility list; derived from `narratives`, nothing stored |
|
|
43
|
+
| `POST` | `/api/revive/:nid` | Revive a dead agent session (`{ prompt?, no_prompt?, agent? }`) — see [Revive API](#revive-api) |
|
|
44
|
+
| `POST` | `/api/search` | Semantic search over narrative history (`{ query, limit?, brane_ids? }`) |
|
|
45
|
+
| `POST` | `/api/cost/backfill` | Sweep the usage ledger over every narrative with a bound session (`{ dry_run? }`) — see [Cost Ledger](cost-ledger.md) |
|
|
46
|
+
| `POST` | `/api/cost/reprice` | Re-price recorded ledger rows under the current price table and move each card total by the correction (`{ dry_run? }`) — see [Re-pricing](cost-ledger.md#re-pricing) |
|
|
47
|
+
| `POST` | `/api/tokens` | Mint a device token (`{ label? }` → `{ token, id, label }`); only its hash is kept, so this response is the one copy. `brane meld` calls it for every QR — see [Token Rotation](token-rotation.md). From the shell it waits for the [presence check](presence.md#the-gate) (403, 409 or 429 when refused); an agent's token gets 403 unasked |
|
|
48
|
+
| `POST` | `/api/tokens/remote` | A device token for a daemon on another machine, and its hash (`{ token, hash }`), for `brane meld --remote`; nothing is stored, so this response is the one copy of either. Gated like `POST /api/tokens`: the shell waits for the [presence check](presence.md#the-gate), an agent's token gets 403 unasked |
|
|
49
|
+
| `GET` | `/api/tokens` | The Devices list: every live token (`{ tokens }`), each with `device` ("Safari on iPhone", from the User-Agent it was first presented with), `remote_addr`, the latest sighting and `elsewhere_at` (presented from a second address), `minted_by_device`, `last_used_at`, `current` (the requester), `connected`, and `unused` / `expires_at` for a QR nobody has scanned — see [Token Rotation](token-rotation.md#the-devices-list) |
|
|
50
|
+
| `POST` | `/api/tokens/:id/revoke` | Revoke one device token, bodiless, plus `?also=a,b` from the devices it added (each row's `descendants`); every socket it opened (control, terminal, MCP) closes with 4401. Refuses this machine's own `local` row (400), the requester's own (409), and an id that is not live (404), all before any prompt. Signing out a device waits for the [presence check](presence.md#the-gate) from the shell and is refused (403) to an agent's token; revoking an agent does neither |
|
|
51
|
+
| `POST` | `/api/token/rotate` | From a device: mint a pending replacement token, returned as `{ token, expires_at, replaced }`; the old one is retired on the new one's first use — see [Token Rotation](token-rotation.md). From this machine's shell (no token, over `daemon.sock`): wait for the [presence check](presence.md#the-gate), then sign out every device in this one request, answering `{ signed_out, replaced }`. An agent's token gets 403 |
|
|
52
|
+
| — | `BRANE_DEVICE_TOKEN_SHA256` | Not a route: comma-separated SHA-256 hashes in the daemon's environment, each adopted at startup as a device row unless ever seen before, then dropped from the environment. How `brane meld --remote` hands a remote daemon (a Fly machine, say) a token minted elsewhere — see [Presence Check](presence.md#the-gate) |
|
|
53
|
+
| `POST` | `/api/presence/cancel` | Close the presence prompt that is up (`?id=` from the control socket's `presence` message, so a stale tap closes nothing) → `{ canceled }`. Device tokens only (403 otherwise); there is no approve |
|
|
54
|
+
| `GET` | `/api/absorb/survey` | Every session store brane can look for under the daemon's HOME, with per-session `new / changed / unchanged / managed` counts for the readable ones and a found-flag for the rest; reads no content — see [Absorb](absorb.md) |
|
|
55
|
+
| `POST` | `/api/absorb` | Import conversation history (`{ provider, path?, fold?, identity?, brane?, dry_run?, since?, session?, rebuild? }`); answers as an NDJSON progress stream — see [Absorb](absorb.md) |
|
|
56
|
+
| `POST` | `/api/memory/index` | Run a memory indexing pass (`{ rebuild? }`), queued behind the daemon's own background pass; returns `IndexStats` |
|
|
57
|
+
| `GET` | `/api/files?nid=<nid>&path=<rel>[&raw=1][&offset=N]` | Browse a narrative's fold, archives included — see [Files API](files-view.md#files-api) and [Files View Formats](files-view.md#files-view-formats) |
|
|
58
|
+
| `GET` | `/api/files/list?nid=<nid>` | Flat list of every file path under the fold (for fuzzy find) |
|
|
59
|
+
| `GET` | `/api/files/find?nid=<nid>&path=<rel>&q=<query>[&from=N]` | Search a file's bytes for the hex view's 🔎, as an NDJSON stream — see [Searching the bytes](files-view.md#searching-the-bytes) |
|
|
60
|
+
| `POST` | `/api/highlight` | Shiki-highlight a snippet (`{ content, lang }`) — used by chat code blocks |
|
|
61
|
+
| `POST` | `/api/diagram` | Render a ```mermaid fence to static SVG (`{ source }`) — `{ diagram: null }` means "not a diagram brane draws" and is a normal answer |
|
|
62
|
+
| `POST` | `/api/wiki/search` | Semantic search over wiki pages (`{ query, limit?, brane_ids? }`) — see [Wiki: Local and Shared](wiki.md) |
|
|
63
|
+
| `GET` | `/api/wiki/read?slug=&source=&anchor=&window=` | Read a wiki page or one section with heading neighbors |
|
|
64
|
+
| `GET` | `/api/wiki/asset?source=&path=` | Serve a binary asset from a wiki source dir (traversal-confined) |
|
|
65
|
+
|
|
66
|
+
## WebSocket Endpoints
|
|
67
|
+
|
|
68
|
+
| Path | Purpose |
|
|
69
|
+
|------|---------|
|
|
70
|
+
| `WS /ws/control` | Real-time control channel (event hints, signals, status/meta broadcasts) |
|
|
71
|
+
| `WS /ws/terminal/:nid` | tmux terminal mirroring (capture-pane at 10fps) |
|
|
72
|
+
| `WS /ws/mcp?nid=<id>` | MCP tool bridge (daemon owns all tool logic) |
|
|
73
|
+
|
|
74
|
+
Control WebSocket broadcasts (server → client):
|
|
75
|
+
- `state` — initial state with all narratives
|
|
76
|
+
- `new_events` — event count hints for a narrative
|
|
77
|
+
- `status` — narrative status changes (active, ended, mcp_connected, mcp_disconnected)
|
|
78
|
+
- `awaiting` — `{ narrative, awaiting }`: the agent parked in a blocking `listen` or a `narrate({ wait: true })`, or stopped waiting (`src/daemon/awaiting.ts`). The app shows it as an inset glow on the list card, switcher row and header (`awaitingGlow`, `src/shared/narrative-status.ts`), in a hue this device allocates: chosen once, as far as possible from the other recently glowing narratives' hues, then kept so sessions can be learned by color; 🎨 Recolor moves it to a random hue at least 100° away, so a press changes the color family rather than shading it, and at least 60° from the hues it was recolored away from in the last hour, so repeated presses explore rather than toggle. It stays clear of the other sessions' hues where the wheel allows; when crowded, that clearance gives way before the step or the return does (`src/shared/glow-hues.ts`, `localStorage` only). Its own message type, not a `status`, so an older app ignores it
|
|
79
|
+
- `meta` — narrative metadata updates (summary, title)
|
|
80
|
+
- `heartbeat` — 30-second keepalive
|
|
81
|
+
|
|
82
|
+
Control WebSocket commands (client → server):
|
|
83
|
+
- `signal` — send signal to a narrative (with payload support); stamped and refused as `POST /api/narratives/:id/signal` is, with a refusal answered by a `signal_refused` frame
|
|
84
|
+
- `subscribe` / `unsubscribe` — narrative event subscriptions
|
|
85
|
+
|
|
86
|
+
## Spawn API
|
|
87
|
+
|
|
88
|
+
`POST /api/spawn` accepts `{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }` and returns the new narrative object with `mcp_connected` status. `terminal_size` (`{ cols, rows }`) is the grid the requesting viewer's terminal pane last measured; the session starts at it rather than at the last size applied to any session, so its first screen is not redrawn through a resize — see [Launch residue in scrollback](terminal-mirroring.md#launch-residue-in-scrollback). `/api/run` takes it too. The fold picker modal lets the operator choose which registered fold to spawn into. The dialog's Agent list is the built-in adapters plus an "Other…" entry that takes any agent name, as `brane spawn <agent>` does: the daemon accepts any `[A-Za-z0-9_-]+` name it can find with `which`, and an agent without an adapter gets tmux, MCP config and mirroring but no session resumption. What is typed there splits like the args box, so the first word is the agent and the rest lead its `extra_args`.
|
|
89
|
+
|
|
90
|
+
This is the **only** spawn path. The web UI calls it, and so does `brane spawn` — the CLI resolves a daemon (starting one if needed), POSTs here with `fold_path` set from `cwd` and `-- <args>` as `extra_args`, and then does only terminal-shaped work with the response: `--meld`'s QR code and `--attach`. It does not open the database or create tmux sessions itself, so tmux bootstrap (`ensureTmux`), terminal sizing, and MCP config writing happen once, daemon-side, identically for both callers. Two consequences follow from that and are intended: the agent binary is resolved against the *daemon's* `PATH`, and MCP config files point at the checkout the *daemon* runs from — not the CLI you invoked.
|
|
91
|
+
|
|
92
|
+
`src/daemon/client.ts` is the CLI-side counterpart to `daemon/server.ts`: `resolveDaemonEndpoint()` (find-or-start, and pick a locally reachable origin — under TLS that means the HTTP fallback port, since the Tailscale cert is never valid for `127.0.0.1`) plus `daemonRequest()` (bearer auth, JSON, rethrows the daemon's own `error` string).
|
|
93
|
+
|
|
94
|
+
## Revive API
|
|
95
|
+
|
|
96
|
+
`POST /api/revive/:nid` accepts `{ prompt?, no_prompt?, agent? }` and returns `{ status, nid, agent }`. It is the **only** revive path — the web app's revive menu item, the `revive` MCP tool, and `brane revive` are all clients of it, in the same shape as the Spawn API above. The CLI resolves a daemon, POSTs, and prints; it does not open the database, resolve adapters, or create tmux sessions.
|
|
97
|
+
|
|
98
|
+
`status` is the outcome, and clients must not assume `nid` is the one they asked for:
|
|
99
|
+
|
|
100
|
+
| `status` | Meaning |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `revived` | The session was dead and has been restarted in place, resuming its agent session (Claude forks with `--fork-session` when the original is still live). |
|
|
103
|
+
| `already_running` | tmux still had the session. A `prompt` is delivered if given; a stale `ended` row is self-healed to `active` and broadcast. |
|
|
104
|
+
| `continued` | The narrative can't be resumed in place — it was absorbed, or `agent` names a different agent — so a **twin** was spawned that reads its history. `nid` is the twin's, not the requested one. |
|
|
105
|
+
|
|
106
|
+
## The CLI is a daemon client
|
|
107
|
+
|
|
108
|
+
Spawn and revive set the pattern; the rest of the CLI follows it. A command
|
|
109
|
+
that touches narratives, folds, sessions, or the memory index does so by
|
|
110
|
+
calling the daemon, and keeps only terminal-shaped work for itself — resolving
|
|
111
|
+
the operator's relative paths against *this* shell's cwd, printing, attaching
|
|
112
|
+
to tmux. The reasons are the ones the Spawn API section gives, and one more
|
|
113
|
+
that absorb makes concrete: every path the pipeline resolves — payloads, the
|
|
114
|
+
organelle `search.db`, the niche registry — resolves against **the daemon's
|
|
115
|
+
HOME**, and two processes indexing the same cursors (the daemon's background
|
|
116
|
+
pass and an in-process `brane memory index`) was a race nobody had to have.
|
|
117
|
+
|
|
118
|
+
| Command | Calls |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `spawn`, `revive` | `POST /api/spawn`, `/api/revive/:nid` |
|
|
121
|
+
| `list` | `GET /api/narratives`, `GET /api/branes` |
|
|
122
|
+
| `fold` | `POST /api/folds`, `GET /api/folds?archived=1` |
|
|
123
|
+
| `absorb` | `GET /api/absorb/survey`, `POST /api/absorb` — an NDJSON stream, read with `daemonStream()` |
|
|
124
|
+
| `memory log` / `search` / `index` | `GET /api/narratives/recent`, `POST /api/search`, `POST /api/memory/index` |
|
|
125
|
+
| `cost backfill` / `reprice` | `POST /api/cost/backfill`, `POST /api/cost/reprice` |
|
|
126
|
+
| `token list` | `GET /api/tokens` |
|
|
127
|
+
| `token revoke <id>` | `POST /api/tokens/:id/revoke` |
|
|
128
|
+
| `token rotate` | `POST /api/token/rotate` (one request: the shell holds no token to swap) |
|
|
129
|
+
|
|
130
|
+
**How it gets there: `daemon.sock` first.** The daemon also listens on a
|
|
131
|
+
Unix socket beside the database (`~/.brane/daemon.sock`, recorded as
|
|
132
|
+
`meta.daemon_socket`; `src/daemon/local-socket.ts`), with the same request
|
|
133
|
+
and upgrade handlers. `daemonRequest()` and `daemonStream()` send over it
|
|
134
|
+
when it is there. Inside an agent session, which has a token to present,
|
|
135
|
+
they fall back to TCP at `buildDaemonBaseUrl()` when the socket
|
|
136
|
+
cannot be connected to — any failure before it connects, so nothing was
|
|
137
|
+
sent; a failure after that is reported, never retried, so a POST cannot run
|
|
138
|
+
twice. Outside one there is nothing to present over TCP, so there is no
|
|
139
|
+
fallback: the socket is the only way in, and the error says so. `brane mcp` dials `ws://localhost/ws/mcp` with `createConnection` to
|
|
140
|
+
the socket (`mcpDialTarget()`), keeping the path out of the URL, and uses
|
|
141
|
+
TCP whenever the socket does not answer a probe at (re)connect. Reaching the socket takes search permission on the
|
|
142
|
+
0700 `~/.brane`, so the kernel admits only this Unix account (and root),
|
|
143
|
+
which loopback TCP cannot promise. So a request over it that presents **no
|
|
144
|
+
token** acts as this account: the live `local` row ("this machine"), with
|
|
145
|
+
the same last-seen bookkeeping a presented token gets
|
|
146
|
+
(`authenticateRequest()` in `server.ts`, `TokenStore.account()`). That holds
|
|
147
|
+
only while `~/.brane` is owned by the daemon's uid with no group or other
|
|
148
|
+
bits (`accountDirTrust()`), checked on every such request, since both
|
|
149
|
+
chmods that set it up swallow their errors; otherwise the request is
|
|
150
|
+
refused and the daemon logs why, once per reason until one is let in again.
|
|
151
|
+
A tokenless request with an `Origin` header, or a `Host` other than
|
|
152
|
+
`localhost`, is refused too: the CLI and bridge send neither, and that is
|
|
153
|
+
the shape of a browser's request arriving through a relay to the socket.
|
|
154
|
+
An empty token counts as none. No token over TCP, even
|
|
155
|
+
loopback, is refused as before. A client sends a token only inside an
|
|
156
|
+
agent session (`resolveCredential()`); everything else on this machine sends
|
|
157
|
+
none, over the socket, and never falls back to TCP, where it would only be
|
|
158
|
+
a 401. No client reads `.daemon_token`, which older branes kept a local
|
|
159
|
+
token in — see [No local token](token-rotation.md#no-local-token).
|
|
160
|
+
A crashed daemon leaves its socket file behind; the next one, once
|
|
161
|
+
it holds the claim, removes it only if it is a socket whose connect is
|
|
162
|
+
refused (`ECONNREFUSED`), and otherwise leaves the path alone; until it is
|
|
163
|
+
cleared, agents and devices are served over TCP and this machine's shell,
|
|
164
|
+
which holds no token, cannot reach the daemon (`brane doctor` says why).
|
|
165
|
+
|
|
166
|
+
What stays local is what must: `daemon` *is* the daemon; `mcp` is the bridge
|
|
167
|
+
into it; `config` is how `token` and `default.brane` are set before a daemon
|
|
168
|
+
exists (and the daemon itself reads them through `getConfig`); `certify`
|
|
169
|
+
writes the cert files the daemon hot-reloads; `doctor` is a diagnostic and
|
|
170
|
+
must not depend on the thing it diagnoses; `attach` is tmux; `meld` already
|
|
171
|
+
finds-or-starts the daemon and reads two `meta` rows to build a URL.
|
|
172
|
+
|
|
173
|
+
Every client calls `warnIfVersionMismatch()` first, because a request field
|
|
174
|
+
this CLI knows about is silently ignored by an older daemon. A new
|
|
175
|
+
source-reading habit to resist: a command that wants a new fact should get it
|
|
176
|
+
from a route (adding one if needed), not from `openDatabase()` — the moment a
|
|
177
|
+
command opens the database it has a second implementation to keep in step.
|
|
178
|
+
|
|
179
|
+
## Run API
|
|
180
|
+
|
|
181
|
+
`POST /api/run` accepts `{ command, fold_path }` where `command` is an array of strings. Creates a tracked tmux session for non-agent processes (lazygit, htop, etc.) with terminal mirroring but no MCP, LIVE loop, or agent adapter. The narrative gets `role: "utility"` and `agent_type` set to the command basename. The spawn dialog reaches it through its **Utility** side (the label opposite **Agent**), whose list is `GET /api/run-history` plus an "Other…" entry for a command never run before. The dialog is its only client: the `brane run` CLI command was retired (2026-10-10). A utility session's environment carries no agent token, so it is this account's standing in one call, and the route is closed to agents (`src/daemon/agent-scope.ts`); the CLI command added nothing the dialog lacks (same route, same env scrubbing, no env passthrough from the calling shell), and keeping it kept a `brane` verb in every agent's shell whose only job was to start such a session. `brane attach <nid>` attaches to one the app started.
|
|
182
|
+
|
|
183
|
+
## Folds API
|
|
184
|
+
|
|
185
|
+
`GET /api/folds` returns registered fold directories sorted by `created_at` descending, archived ones only with `?archived=1`. Used by the Expo app to populate the spawn fold picker.
|
|
186
|
+
|
|
187
|
+
`POST /api/folds` with `{ path, brane_id? }` registers one — the app's "add a spawn point" affordance and `brane fold` are both clients — and returns `{ fold, created }`. It is idempotent: re-registering un-archives the row and, with `brane_id`, rebinds it (`brane fold --brane`, and absorb tagging a fold with the brane it imports into). The implementation is `registerFold` in `src/folds.ts`, shared with absorb.
|
|
188
|
+
|
|
189
|
+
**Who may register what.** A phone on the tailnet must not be able to make `/` or `/etc` a spawn point, so a remote client's folds are confined to the operator's home directory (403 outside it, and for `$HOME` itself). The operator's own shell has never been so limited — `brane fold /Volumes/scratch/project` has always worked — and now that the CLI reaches this through the daemon, that reach is recognized on the wire as **loopback**: `isLoopbackRequest()` in `src/daemon/server.ts` grants it to a connection on `daemon.sock` and to `127.0.0.0/8` and `::1`, which is what the CLI dials (`daemon.sock`, else `buildDaemonBaseUrl`) and what nothing off this machine can. The one gap is the TLS window where the fallback port isn't recorded yet and the CLI dials the Tailscale hostname; it lasts about a second after daemon start and the request is then simply confined. `foldPathRefusal()` is the pure decision, pinned by `src/test/thin-cli.test.ts`. Read the rule as a guard against **mistakes**, not a security boundary: the token already grants keyboard input to every tmux session, so a holder who wanted `/etc` could have it; what the rule prevents is a phone's file picker or a typo quietly making the wrong directory a spawn point. That is also why `POST /api/absorb` confines the *fold* it registers but not the export *path* it reads.
|