brane 0.0.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -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 +116 -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 +332 -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 +1094 -0
- package/dist/commands/token.js +153 -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 +6349 -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 +633 -0
- package/dist/db/tokens.js +583 -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/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 +82 -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 +197 -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/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 +49 -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 +262 -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 +85 -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 +137 -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 +115 -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/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -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 +581 -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/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -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 +53 -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 +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -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 +81 -23
- 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 +48 -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/.npmignore +0 -2
- package/.travis.yml +0 -5
- package/index.js +0 -8
- package/index.js~ +0 -12
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { getMaxSeq } from "./events.js";
|
|
3
|
+
/** Serialize a captured value for storage — verbatim, no redaction, no cap.
|
|
4
|
+
* Deliberate (operator ruling, claude-xlolbv6l:94): the row is the durable
|
|
5
|
+
* record, and anything destroyed at write time is unrecoverable, which is
|
|
6
|
+
* exactly the loss the Golden Rule exists to prevent. If a value ever needs
|
|
7
|
+
* hiding or shrinking, do it on the read/push path, where a better filter
|
|
8
|
+
* applies retroactively to every stored row (see excerptForPush).
|
|
9
|
+
*
|
|
10
|
+
* `leanLoopCapture` below is the one exception, and it is not really a
|
|
11
|
+
* counter-example: it declines to record a *second copy* of the narrative,
|
|
12
|
+
* not a value of its own. Nothing it drops was ever the row's to lose.
|
|
13
|
+
*
|
|
14
|
+
* Returns null for undefined/null so absent stays absent. */
|
|
15
|
+
export function prepareCaptureText(value) {
|
|
16
|
+
if (value === undefined || value === null)
|
|
17
|
+
return null;
|
|
18
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
/** The loop's own MCP tools, whose payloads are the narrative itself. */
|
|
21
|
+
const NARRATE_TOOL = "mcp__brane__narrate";
|
|
22
|
+
const LISTEN_TOOL = "mcp__brane__listen";
|
|
23
|
+
/** Unwrap one layer of the MCP result envelope, which vendors apply
|
|
24
|
+
* inconsistently: some hooks report the tool's object, some report
|
|
25
|
+
* `{ content: [{ type: "text", text }] }` with the object as JSON inside. */
|
|
26
|
+
function unwrapToolResult(value) {
|
|
27
|
+
const asObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
|
|
28
|
+
const parse = (v) => {
|
|
29
|
+
if (typeof v !== "string")
|
|
30
|
+
return v;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(v);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const outer = asObject(parse(value));
|
|
39
|
+
if (!outer)
|
|
40
|
+
return null;
|
|
41
|
+
if (!Array.isArray(outer.content))
|
|
42
|
+
return outer;
|
|
43
|
+
const text = outer.content.find((part) => typeof part?.text === "string")?.text;
|
|
44
|
+
return asObject(parse(text));
|
|
45
|
+
}
|
|
46
|
+
/** What to store for a call the loop made about itself.
|
|
47
|
+
*
|
|
48
|
+
* `narrate` and `listen` carry the narrative in their payloads, so capturing
|
|
49
|
+
* them verbatim writes the chat into the database two more times: a narration
|
|
50
|
+
* lands in `events`, again as the tool's `say`, and a third time in the
|
|
51
|
+
* receipt that echoes the event just written. Measured on one live narrative,
|
|
52
|
+
* the narrate rows came to 2.4× the bytes of the narrative they described
|
|
53
|
+
* (claude-n1izig04:40/:42).
|
|
54
|
+
*
|
|
55
|
+
* Size is the smaller half. The event bodies and cursor watermarks would also
|
|
56
|
+
* make the permanent log record exactly how the append-only stream happened to
|
|
57
|
+
* be partitioned by listen boundaries — an artifact of when an agent woke up
|
|
58
|
+
* rather than of anything that happened, and the same underlying log either
|
|
59
|
+
* way (operator invariant, claude-n1izig04:43/:46).
|
|
60
|
+
*
|
|
61
|
+
* What survives is chosen by one test: **does this exist anywhere else?**
|
|
62
|
+
*
|
|
63
|
+
* - `listen` keeps its cursors, verbatim. They do implicitly encode how one
|
|
64
|
+
* session was chunked, and the operator has weighed that and accepted it:
|
|
65
|
+
* "not the end of the world" (claude-n1izig04:50/:51). They are also the
|
|
66
|
+
* only record of *who was being watched* — the events are in both logs, the
|
|
67
|
+
* attention is only here.
|
|
68
|
+
* - `listen` output goes entirely. Every byte of it is in `events`.
|
|
69
|
+
* - `narrate` keeps `{nid, seq}`, the pointer the UI renders and the only part
|
|
70
|
+
* of an outgoing message this side should carry (claude-n1izig04:48).
|
|
71
|
+
* - `narrate`'s `say` is dropped **only against proof that it landed** — a
|
|
72
|
+
* `seq` in the result means the text is in `events` and this copy is
|
|
73
|
+
* redundant. Without that proof it is kept, because the words of a narrate
|
|
74
|
+
* that *failed* exist nowhere else, and a rule that couldn't tell the two
|
|
75
|
+
* apart would quietly destroy the one case that mattered. In practice the
|
|
76
|
+
* `PreToolUse` row keeps the text and the `PostToolUse` write clears it.
|
|
77
|
+
*
|
|
78
|
+
* Note the invariant is weaker than it looks and cannot be made stronger here:
|
|
79
|
+
* every row is anchored to `after_seq`, the narrative head at capture time, so
|
|
80
|
+
* a listen row still says where in the stream it sat. Dropping watermarks
|
|
81
|
+
* declines to keep a second, exact, machine-readable copy of that; it does not
|
|
82
|
+
* erase it. */
|
|
83
|
+
export function leanLoopCapture(narrativeId, name, input, output) {
|
|
84
|
+
if (name === LISTEN_TOOL)
|
|
85
|
+
return { input, output: undefined };
|
|
86
|
+
if (name !== NARRATE_TOOL)
|
|
87
|
+
return { input, output };
|
|
88
|
+
const target = unwrapToolResult(input)?.nid;
|
|
89
|
+
const foreign = typeof target === "string" && target !== narrativeId;
|
|
90
|
+
const seq = unwrapToolResult(output)?.seq;
|
|
91
|
+
if (typeof seq !== "number") {
|
|
92
|
+
// No proof the narration is in the log yet — keep the call as it came.
|
|
93
|
+
return { input, output };
|
|
94
|
+
}
|
|
95
|
+
// `{}` rather than nothing: the upsert coalesces, so only a non-null value
|
|
96
|
+
// can replace the verbatim input the PreToolUse row already stored.
|
|
97
|
+
return {
|
|
98
|
+
input: foreign ? { nid: target } : {},
|
|
99
|
+
output: foreign ? { nid: target, seq } : {},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Cut `text` to at most `capBytes` of UTF-8, on a character boundary.
|
|
103
|
+
* Encode once and back off from the byte budget while it lands on a
|
|
104
|
+
* continuation byte (0b10xxxxxx), i.e. inside a multi-byte sequence. Slicing
|
|
105
|
+
* the *string* instead would split an astral character's surrogate pair and
|
|
106
|
+
* leave a lone surrogate rendering as U+FFFD; slicing the *bytes* without the
|
|
107
|
+
* back-off would do the same one layer down (claude-8fkbabsx:9, finding 7).
|
|
108
|
+
* Single pass — the old loop rescanned a shrinking string geometrically. */
|
|
109
|
+
function excerptText(text, capBytes) {
|
|
110
|
+
if (text === null)
|
|
111
|
+
return { text, truncated: false };
|
|
112
|
+
const buf = Buffer.from(text, "utf-8");
|
|
113
|
+
if (buf.length <= capBytes)
|
|
114
|
+
return { text, truncated: false };
|
|
115
|
+
let end = capBytes;
|
|
116
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80)
|
|
117
|
+
end--;
|
|
118
|
+
return { text: buf.subarray(0, end).toString("utf-8"), truncated: true };
|
|
119
|
+
}
|
|
120
|
+
/** Cap on input/output text in a *list* — the window read, the catch-up read,
|
|
121
|
+
* and the inline push.
|
|
122
|
+
*
|
|
123
|
+
* Much smaller than the push cap above, because a listed row is drawn as one
|
|
124
|
+
* line: a name, a status, a duration, and an argument summary the UI clamps to
|
|
125
|
+
* 240 characters. The only payload a collapsed row genuinely needs is the
|
|
126
|
+
* `{nid, seq}` pointer on a foreign narrate, which is about thirty bytes.
|
|
127
|
+
* Everything else was being shipped for a detail view that now fetches the row
|
|
128
|
+
* whole when the operator opens it (claude-n1izig04:75).
|
|
129
|
+
*
|
|
130
|
+
* Measured: one narrative's 265 rows were a 3.65MB response at the old cap. */
|
|
131
|
+
export const LIST_EXCERPT_BYTES = 1_024;
|
|
132
|
+
/** Floor below which a cap is read as "no cap given" rather than honored.
|
|
133
|
+
*
|
|
134
|
+
* No caller ever means to excerpt a tool payload to a handful of bytes: the
|
|
135
|
+
* smallest thing this function is asked to preserve is a `{nid, seq}` pointer,
|
|
136
|
+
* which is about thirty. A number below the floor is therefore not a small
|
|
137
|
+
* budget, it is a mistake — and the mistake that actually happened was
|
|
138
|
+
* `.map(excerptForPush)`, where `map`'s index argument landed in `capBytes`
|
|
139
|
+
* and blanked every inline-pushed row's input while storage stayed whole
|
|
140
|
+
* (claude-bk6vwpc8:9). Treating it as absent makes that whole family of
|
|
141
|
+
* index-as-argument slips inert instead of silently emptying the wire.
|
|
142
|
+
*
|
|
143
|
+
* NaN falls through the same door — `NaN >= floor` is false — which matters
|
|
144
|
+
* because a mistyped config parses to NaN and NaN fails every comparison
|
|
145
|
+
* quietly (the trap `numericConfig` exists to close). */
|
|
146
|
+
const MIN_EXCERPT_CAP_BYTES = 64;
|
|
147
|
+
/** Shape a row for a list or an inline push: oversized input/output become
|
|
148
|
+
* excerpts with `input_truncated`/`output_truncated` flags, so a client knows
|
|
149
|
+
* the daemon holds more and can ask for that row in full. The database row is
|
|
150
|
+
* untouched. */
|
|
151
|
+
export function excerptForPush(row, capBytes = LIST_EXCERPT_BYTES) {
|
|
152
|
+
const cap = capBytes >= MIN_EXCERPT_CAP_BYTES ? capBytes : LIST_EXCERPT_BYTES;
|
|
153
|
+
const input = excerptText(row.input, cap);
|
|
154
|
+
const output = excerptText(row.output, cap);
|
|
155
|
+
if (!input.truncated && !output.truncated)
|
|
156
|
+
return row;
|
|
157
|
+
return {
|
|
158
|
+
...row,
|
|
159
|
+
input: input.text,
|
|
160
|
+
output: output.text,
|
|
161
|
+
...(input.truncated ? { input_truncated: true } : {}),
|
|
162
|
+
...(output.truncated ? { output_truncated: true } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Read a dotted path out of an untrusted payload, or undefined. */
|
|
166
|
+
function pluck(source, path) {
|
|
167
|
+
let cursor = source;
|
|
168
|
+
for (const key of path.split(".")) {
|
|
169
|
+
if (typeof cursor !== "object" || cursor === null)
|
|
170
|
+
return undefined;
|
|
171
|
+
cursor = cursor[key];
|
|
172
|
+
}
|
|
173
|
+
return cursor;
|
|
174
|
+
}
|
|
175
|
+
/** First defined value among `paths`, read from `raw`. */
|
|
176
|
+
function firstOf(raw, paths) {
|
|
177
|
+
for (const path of paths) {
|
|
178
|
+
const value = pluck(raw, path);
|
|
179
|
+
if (value !== undefined && value !== null)
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
/** Where each canonical field hides in the harnesses that don't use Claude's
|
|
185
|
+
* names. Canonical first, so a payload that already speaks the contract is
|
|
186
|
+
* returned unchanged.
|
|
187
|
+
*
|
|
188
|
+
* - **goose** (Open Plugins): `event`, and the response under `tool_output`.
|
|
189
|
+
* - **crush**: `event`; `tool_input` is already an object "for compatibility
|
|
190
|
+
* with Claude Code hooks" (their source comment). No Post event exists.
|
|
191
|
+
* - **cline**: `hookName`, with the call under `tool_call` / `preToolUse` and
|
|
192
|
+
* the outcome under `tool_result` / `postToolUse`. */
|
|
193
|
+
const HOOK_FIELD_ALIASES = {
|
|
194
|
+
hook_event_name: ["hook_event_name", "event", "hookName"],
|
|
195
|
+
tool_name: [
|
|
196
|
+
"tool_name",
|
|
197
|
+
"tool_call.name",
|
|
198
|
+
"preToolUse.toolName",
|
|
199
|
+
"postToolUse.toolName",
|
|
200
|
+
"tool_result.name",
|
|
201
|
+
],
|
|
202
|
+
tool_input: [
|
|
203
|
+
"tool_input",
|
|
204
|
+
"tool_call.input",
|
|
205
|
+
"preToolUse.parameters",
|
|
206
|
+
"postToolUse.parameters",
|
|
207
|
+
"tool_result.input",
|
|
208
|
+
],
|
|
209
|
+
tool_response: ["tool_response", "tool_output", "postToolUse.result", "tool_result.output"],
|
|
210
|
+
tool_use_id: [
|
|
211
|
+
"tool_use_id",
|
|
212
|
+
"tool_call.id",
|
|
213
|
+
"tool_result.id",
|
|
214
|
+
"tool_result.tool_call_id",
|
|
215
|
+
"tool_call_id",
|
|
216
|
+
],
|
|
217
|
+
duration_ms: ["duration_ms", "postToolUse.executionTimeMs", "execution_time_ms"],
|
|
218
|
+
// No `parent_agent_id` here on purpose: cline sends one, but it identifies a
|
|
219
|
+
// sub-agent, not the tool call that spawned this one. Storing it as
|
|
220
|
+
// `parent_id` would draw a nesting edge that doesn't exist.
|
|
221
|
+
error: ["error", "tool_result.error", "postToolUse.error"],
|
|
222
|
+
parent_tool_use_id: ["parent_tool_use_id", "parent_tool_use.id"],
|
|
223
|
+
failed: [],
|
|
224
|
+
};
|
|
225
|
+
/** Event names that announce a call rather than report one.
|
|
226
|
+
*
|
|
227
|
+
* `pretooluse` covers claude, goose and crush. Cline is the odd one: it names
|
|
228
|
+
* its hooks after the *thing* rather than the phase — `tool_call` for the
|
|
229
|
+
* pre-hook and `tool_result` for the post — so its pre event has to be listed
|
|
230
|
+
* by name or every cline call would be recorded as finished on arrival.
|
|
231
|
+
*
|
|
232
|
+
* Compared lowercased and stripped of separators, because goose and crush both
|
|
233
|
+
* accept case and underscore variants of their own event names. */
|
|
234
|
+
const PRE_TOOL_USE_EVENTS = new Set(["pretooluse", "toolcall"]);
|
|
235
|
+
function isPreToolUseEvent(name) {
|
|
236
|
+
return (typeof name === "string" && PRE_TOOL_USE_EVENTS.has(name.toLowerCase().replace(/[_\-\s]/g, "")));
|
|
237
|
+
}
|
|
238
|
+
/** Translate one harness's hook payload into the shape the ingest expects.
|
|
239
|
+
*
|
|
240
|
+
* Dispatch is on the payload's *shape*, not on the narrative's `agent_type`.
|
|
241
|
+
* Both were available — the ingest route knows the bound narrative's agent —
|
|
242
|
+
* and shape wins for two reasons. An external agent that connected without
|
|
243
|
+
* `brane spawn` has whatever `agent_type` its row happened to get, so a
|
|
244
|
+
* per-agent switch would silently stop capturing the moment the label and the
|
|
245
|
+
* binary disagree. And the aliases don't actually collide: no harness uses
|
|
246
|
+
* another's field name for a different thing, so reading them all costs
|
|
247
|
+
* nothing and keeps this function a pure map from bytes to bytes.
|
|
248
|
+
*
|
|
249
|
+
* The one translation that is not cosmetic is `hook_event_name`. Both goose
|
|
250
|
+
* and crush send `event: "PreToolUse"`, which `toolUseFromClaudeHook` does not
|
|
251
|
+
* recognize — so an un-normalized pre-hook falls through to the completion
|
|
252
|
+
* path and records the call as `completed`, with a null output, at the instant
|
|
253
|
+
* it starts. Every long call would show as having finished immediately. */
|
|
254
|
+
export function normalizeHookPayload(raw) {
|
|
255
|
+
const out = {};
|
|
256
|
+
const event = firstOf(raw, HOOK_FIELD_ALIASES.hook_event_name);
|
|
257
|
+
if (isPreToolUseEvent(event))
|
|
258
|
+
out.hook_event_name = "PreToolUse";
|
|
259
|
+
else if (typeof event === "string")
|
|
260
|
+
out.hook_event_name = event;
|
|
261
|
+
const name = firstOf(raw, HOOK_FIELD_ALIASES.tool_name);
|
|
262
|
+
if (typeof name === "string")
|
|
263
|
+
out.tool_name = name;
|
|
264
|
+
const input = firstOf(raw, HOOK_FIELD_ALIASES.tool_input);
|
|
265
|
+
if (input !== undefined)
|
|
266
|
+
out.tool_input = input;
|
|
267
|
+
const response = firstOf(raw, HOOK_FIELD_ALIASES.tool_response);
|
|
268
|
+
if (response !== undefined)
|
|
269
|
+
out.tool_response = response;
|
|
270
|
+
const id = firstOf(raw, HOOK_FIELD_ALIASES.tool_use_id);
|
|
271
|
+
if (typeof id === "string")
|
|
272
|
+
out.tool_use_id = id;
|
|
273
|
+
const duration = firstOf(raw, HOOK_FIELD_ALIASES.duration_ms);
|
|
274
|
+
if (typeof duration === "number" && Number.isFinite(duration))
|
|
275
|
+
out.duration_ms = duration;
|
|
276
|
+
const error = firstOf(raw, HOOK_FIELD_ALIASES.error);
|
|
277
|
+
if (typeof error === "string" && error !== "")
|
|
278
|
+
out.error = error;
|
|
279
|
+
else if (error !== undefined)
|
|
280
|
+
out.error = JSON.stringify(error);
|
|
281
|
+
const parent = firstOf(raw, HOOK_FIELD_ALIASES.parent_tool_use_id);
|
|
282
|
+
if (typeof parent === "string")
|
|
283
|
+
out.parent_tool_use_id = parent;
|
|
284
|
+
// Outcome reported as a flag or a dedicated event — see `failed` above.
|
|
285
|
+
const failureEvent = typeof event === "string" && /failure|failed|error/i.test(event) && !isPreToolUseEvent(event);
|
|
286
|
+
if (failureEvent || pluck(raw, "postToolUse.success") === false)
|
|
287
|
+
out.failed = true;
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
/** Map a hook payload to a `recordToolUse` call. Pure, so the contract above
|
|
291
|
+
* is pinned by tests rather than claimed by a comment (claude-8fkbabsx:9,
|
|
292
|
+
* finding 4) — the HTTP handler is left doing nothing but routing.
|
|
293
|
+
*
|
|
294
|
+
* Returns null when `tool_name` is absent: without it there is no call to
|
|
295
|
+
* record, and the caller answers 400. A payload is "failed" if it carries an
|
|
296
|
+
* `error`, if the response is an object with `is_error: true` — the shape
|
|
297
|
+
* Claude uses to report a tool that ran and rejected — or if `failed` is set,
|
|
298
|
+
* which is how the other harnesses' out-of-band outcome signals arrive.
|
|
299
|
+
*
|
|
300
|
+
* A `PreToolUse` payload is the same call seen earlier: it names the tool
|
|
301
|
+
* before it runs, so the row can appear while the work is happening rather
|
|
302
|
+
* than after it's over (claude-mvpe4t45:46). It carries no response, no
|
|
303
|
+
* duration and no outcome, so mapping it through the completion path — where
|
|
304
|
+
* "no error present" means success — would report every call as finished the
|
|
305
|
+
* instant it began. It maps to `pending` instead, and the PostToolUse upsert
|
|
306
|
+
* fills in the rest. */
|
|
307
|
+
export function toolUseFromClaudeHook(payload, extra) {
|
|
308
|
+
if (!payload.tool_name)
|
|
309
|
+
return null;
|
|
310
|
+
if (payload.hook_event_name === "PreToolUse") {
|
|
311
|
+
return {
|
|
312
|
+
dedupKey: payload.tool_use_id,
|
|
313
|
+
name: payload.tool_name,
|
|
314
|
+
input: payload.tool_input,
|
|
315
|
+
status: "pending",
|
|
316
|
+
source: extra.source,
|
|
317
|
+
agentType: extra.agentType,
|
|
318
|
+
parentId: payload.parent_tool_use_id,
|
|
319
|
+
startedAt: extra.now ?? Date.now(),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const response = payload.tool_response;
|
|
323
|
+
const failed = payload.failed === true ||
|
|
324
|
+
payload.error !== undefined ||
|
|
325
|
+
(typeof response === "object" &&
|
|
326
|
+
response !== null &&
|
|
327
|
+
response.is_error === true);
|
|
328
|
+
return {
|
|
329
|
+
dedupKey: payload.tool_use_id,
|
|
330
|
+
name: payload.tool_name,
|
|
331
|
+
input: payload.tool_input,
|
|
332
|
+
status: failed ? "failed" : "completed",
|
|
333
|
+
output: response,
|
|
334
|
+
durationMs: payload.duration_ms,
|
|
335
|
+
error: payload.error,
|
|
336
|
+
source: extra.source,
|
|
337
|
+
agentType: extra.agentType,
|
|
338
|
+
parentId: payload.parent_tool_use_id,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** Content-hash fallback identity for vendors that don't supply a tool_use_id. */
|
|
342
|
+
export function contentDedupKey(narrativeId, startedAt, name, input) {
|
|
343
|
+
return createHash("sha256")
|
|
344
|
+
.update(`${narrativeId}|${startedAt}|${name}|${input ?? ""}`)
|
|
345
|
+
.digest("hex");
|
|
346
|
+
}
|
|
347
|
+
/** How far back a completion may reach to find the call it completes.
|
|
348
|
+
*
|
|
349
|
+
* Only relevant to vendors that send no id (see `claimPendingTwin`). The
|
|
350
|
+
* bound has to clear the longest call an agent can legitimately make, because
|
|
351
|
+
* a completion that arrives after the window records a second row instead of
|
|
352
|
+
* closing the first — so this is sized against `MCP_TOOL_TIMEOUT_MS`, the
|
|
353
|
+
* one-hour ceiling brane asks agent clients to allow for a held `listen`,
|
|
354
|
+
* plus a margin. Beyond that the pairing degrades to the old behaviour rather
|
|
355
|
+
* than to something worse. */
|
|
356
|
+
const PAIR_WINDOW_MS = 65 * 60 * 1000;
|
|
357
|
+
/** Find the `pending` row a completion belongs to, when the vendor gave no id
|
|
358
|
+
* to match on.
|
|
359
|
+
*
|
|
360
|
+
* goose is the case this exists for, and its payload is the reason it has to:
|
|
361
|
+
* its `PreToolUse` and `PostToolUse` bodies are **byte-identical apart from
|
|
362
|
+
* the event name** — `{event, matcher_context, session_id, tool_input,
|
|
363
|
+
* tool_name, working_dir}`, verified by capturing live payloads. There is no
|
|
364
|
+
* call id anywhere in either. So `contentDedupKey` — which mixes in
|
|
365
|
+
* `startedAt` to tell two identical calls apart — gives the two halves
|
|
366
|
+
* different keys and every call lands as a `pending` row that never closes
|
|
367
|
+
* plus a separate `completed` one.
|
|
368
|
+
*
|
|
369
|
+
* Matching on (narrative, name, input) instead pairs them. What the timestamp
|
|
370
|
+
* was doing in the key still has to be done by something, though, or two
|
|
371
|
+
* genuinely identical calls collapse into one row — hence:
|
|
372
|
+
*
|
|
373
|
+
* - **Only a completion claims**, never a `pending`. Two concurrent identical
|
|
374
|
+
* calls each insert their own row on the way in; nothing is merged there.
|
|
375
|
+
* - **The claim consumes the row**: matching requires `status = 'pending'`, so
|
|
376
|
+
* the second completion cannot take the same row as the first. Two calls in,
|
|
377
|
+
* two rows out, both closed.
|
|
378
|
+
* - **Newest first.** For sequential calls — the common case — there is only
|
|
379
|
+
* ever one candidate, so the choice is moot. It matters for a *stale*
|
|
380
|
+
* pending (an agent killed mid-call): preferring the newest means a fresh
|
|
381
|
+
* call's own row is claimed and the stale one is left alone, rather than the
|
|
382
|
+
* fresh completion being absorbed by a corpse and its own row orphaned.
|
|
383
|
+
*
|
|
384
|
+
* What this cannot do is tell apart two identical calls that overlap *and*
|
|
385
|
+
* finish out of order — their durations get swapped. Both rows still close,
|
|
386
|
+
* which is the property worth protecting; the alternative is a permanently
|
|
387
|
+
* running row, and no id exists to do better. */
|
|
388
|
+
function claimPendingTwin(db, narrativeId, name, input, now) {
|
|
389
|
+
return db
|
|
390
|
+
.prepare(`SELECT dedup_key, started_at FROM tool_uses
|
|
391
|
+
WHERE narrative_id = ? AND name = ? AND status = 'pending'
|
|
392
|
+
AND input IS ? AND started_at >= ? AND started_at <= ?
|
|
393
|
+
ORDER BY started_at DESC LIMIT 1`)
|
|
394
|
+
.get(narrativeId, name, input, now - PAIR_WINDOW_MS, now);
|
|
395
|
+
}
|
|
396
|
+
/** Upsert a tool call. Idempotent by (narrative_id, dedup_key): the same call
|
|
397
|
+
* arriving twice — PreToolUse then PostToolUse, live by hook then later by
|
|
398
|
+
* session-log backfill — collapses to one row. `after_seq` and `started_at`
|
|
399
|
+
* are preserved: a row's display position is fixed at first sight and never
|
|
400
|
+
* moves, which is what lets clients update in place instead of re-sorting
|
|
401
|
+
* (claude-xlolbv6l:81). With both hooks reporting, first sight is the call's
|
|
402
|
+
* real start rather than a time reconstructed from its duration.
|
|
403
|
+
*
|
|
404
|
+
* An update never erases knowledge. The two hooks are independent HTTP calls
|
|
405
|
+
* and can arrive out of order, so a late `pending` must not undo a completion
|
|
406
|
+
* — it would strand a finished call as permanently running, which on the
|
|
407
|
+
* client reads as a tool that never returned. A status that would regress to
|
|
408
|
+
* `pending` is ignored, and every other mutable field coalesces over what is
|
|
409
|
+
* already stored rather than overwriting it with the null a PreToolUse
|
|
410
|
+
* payload necessarily carries.
|
|
411
|
+
*
|
|
412
|
+
* `rev` is allocated as MAX(rev)+1 per narrative inside the same transaction
|
|
413
|
+
* (better-sqlite3 transactions are synchronous and SQLite has one writer, so
|
|
414
|
+
* this cannot race). Every write bumps it — rev is the sync cursor, and a
|
|
415
|
+
* mutation without a bump would be invisible to a resuming client. */
|
|
416
|
+
export function recordToolUse(db, narrativeId, opts) {
|
|
417
|
+
const durationMs = opts.durationMs ?? null;
|
|
418
|
+
const startedAt = opts.startedAt ?? Date.now() - (durationMs ?? 0);
|
|
419
|
+
// Applied here rather than at the hook boundary so no writer can bypass it:
|
|
420
|
+
// one chokepoint, and the rule holds for absorbed and session-log rows too.
|
|
421
|
+
const lean = leanLoopCapture(narrativeId, opts.name, opts.input, opts.output);
|
|
422
|
+
const input = prepareCaptureText(lean.input);
|
|
423
|
+
const output = prepareCaptureText(lean.output);
|
|
424
|
+
// What the call *was*, before the loop-capture rule shrank it. Both the
|
|
425
|
+
// content key and the twin lookup match on this rather than on `input`,
|
|
426
|
+
// because `leanLoopCapture` can rewrite a narrate's input to `{nid}` — but
|
|
427
|
+
// only on the half that carries a proving `seq`. Matching the stored value
|
|
428
|
+
// would then have a completion looking for `{nid}` while its own pending row
|
|
429
|
+
// holds the whole payload, finding nothing, and opening a second row: the
|
|
430
|
+
// exact defect this pairing exists to remove, reappearing on precisely the
|
|
431
|
+
// tool whose rows the operator reads most.
|
|
432
|
+
const identityInput = prepareCaptureText(opts.input);
|
|
433
|
+
const txn = db.transaction(() => {
|
|
434
|
+
// Identity is computed from what the call actually was, not from the leaner
|
|
435
|
+
// thing we store: two self-narrations both keep a null input, and a key
|
|
436
|
+
// derived from that would tell them apart only by their start millisecond.
|
|
437
|
+
//
|
|
438
|
+
// Resolved inside the transaction because the id-less path *reads* other
|
|
439
|
+
// rows to find the call it completes, and two hooks landing together must
|
|
440
|
+
// not both claim the same one.
|
|
441
|
+
let dedupKey = opts.dedupKey;
|
|
442
|
+
let duration = durationMs;
|
|
443
|
+
if (!dedupKey) {
|
|
444
|
+
const twin = opts.status === "pending"
|
|
445
|
+
? undefined
|
|
446
|
+
: claimPendingTwin(db, narrativeId, opts.name, identityInput, startedAt);
|
|
447
|
+
if (twin) {
|
|
448
|
+
dedupKey = twin.dedup_key;
|
|
449
|
+
// The vendor timed nothing, but the daemon did: the pre hook arrived
|
|
450
|
+
// when the call began and this one arrived when it ended, so the gap
|
|
451
|
+
// between the two receipts *is* the duration. goose ends up with
|
|
452
|
+
// better timing than its own payload carries.
|
|
453
|
+
if (duration === null && startedAt > twin.started_at) {
|
|
454
|
+
duration = startedAt - twin.started_at;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
dedupKey = contentDedupKey(narrativeId, startedAt, opts.name, identityInput);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const revRow = db
|
|
462
|
+
.prepare("SELECT COALESCE(MAX(rev), 0) AS max_rev FROM tool_uses WHERE narrative_id = ?")
|
|
463
|
+
.get(narrativeId);
|
|
464
|
+
const rev = revRow.max_rev + 1;
|
|
465
|
+
const afterSeq = opts.afterSeq ?? getMaxSeq(db, narrativeId);
|
|
466
|
+
db.prepare(`INSERT INTO tool_uses (
|
|
467
|
+
narrative_id, dedup_key, after_seq, started_at, rev,
|
|
468
|
+
name, input, status, output, duration_ms, error,
|
|
469
|
+
source, agent_type, parent_id
|
|
470
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
471
|
+
ON CONFLICT (narrative_id, dedup_key) DO UPDATE SET
|
|
472
|
+
rev = excluded.rev,
|
|
473
|
+
-- Input coalesces like every other mutable field, so an agent whose
|
|
474
|
+
-- PostToolUse payload omits tool_input can't wipe what PreToolUse
|
|
475
|
+
-- recorded. The one write that deliberately *replaces* it is a loop
|
|
476
|
+
-- call proving its text reached the log (see leanLoopCapture), which
|
|
477
|
+
-- is why that path returns a non-null value rather than nothing.
|
|
478
|
+
input = COALESCE(excluded.input, tool_uses.input),
|
|
479
|
+
status = CASE
|
|
480
|
+
WHEN excluded.status = 'pending' AND tool_uses.status != 'pending'
|
|
481
|
+
THEN tool_uses.status
|
|
482
|
+
ELSE excluded.status
|
|
483
|
+
END,
|
|
484
|
+
output = COALESCE(excluded.output, tool_uses.output),
|
|
485
|
+
duration_ms = COALESCE(excluded.duration_ms, tool_uses.duration_ms),
|
|
486
|
+
error = COALESCE(excluded.error, tool_uses.error),
|
|
487
|
+
source = excluded.source`).run(narrativeId, dedupKey, afterSeq, startedAt, rev, opts.name, input, opts.status, output, duration, opts.error ?? null, opts.source, opts.agentType ?? null, opts.parentId ?? null);
|
|
488
|
+
return db
|
|
489
|
+
.prepare("SELECT * FROM tool_uses WHERE narrative_id = ? AND dedup_key = ?")
|
|
490
|
+
.get(narrativeId, dedupKey);
|
|
491
|
+
});
|
|
492
|
+
return txn();
|
|
493
|
+
}
|
|
494
|
+
/** The rows of one inline-push flush: display order, shaped for the wire.
|
|
495
|
+
*
|
|
496
|
+
* Exported as a unit because the bug it replaces was not in `excerptForPush`
|
|
497
|
+
* but in *how it was called*. The daemon flushed with `.map(excerptForPush)`,
|
|
498
|
+
* and `map` passes `(value, index, array)` — so once #375 gave the function a
|
|
499
|
+
* `capBytes` second parameter, every pushed row was excerpted to its own
|
|
500
|
+
* index, row 0 to zero bytes. Storage stayed whole, so args were blank live
|
|
501
|
+
* and correct on reload (claude-bk6vwpc8:9). A unit test on `excerptForPush`
|
|
502
|
+
* passed throughout; only a test of the call site could have failed. This is
|
|
503
|
+
* that call site, reachable without standing up a daemon. */
|
|
504
|
+
export function shapeToolUsesForPush(rows) {
|
|
505
|
+
return [...rows]
|
|
506
|
+
.sort((a, b) => a.after_seq - b.after_seq || a.started_at - b.started_at)
|
|
507
|
+
.map((row) => excerptForPush(row));
|
|
508
|
+
}
|
|
509
|
+
/** The resume point for the window page that follows `rows`, or null if the
|
|
510
|
+
* page was empty. */
|
|
511
|
+
export function toolUseCursor(rows) {
|
|
512
|
+
const last = rows[rows.length - 1];
|
|
513
|
+
if (!last)
|
|
514
|
+
return null;
|
|
515
|
+
return { afterSeq: last.after_seq, startedAt: last.started_at, dedupKey: last.dedup_key };
|
|
516
|
+
}
|
|
517
|
+
/** Display query: rows anchored in [afterSeqGte, afterSeqLt), in display
|
|
518
|
+
* order (rides tool_uses_order). Resume a truncated page from
|
|
519
|
+
* `toolUseCursor(rows)`. */
|
|
520
|
+
export function readToolUseWindow(db, narrativeId, opts = {}) {
|
|
521
|
+
const where = ["narrative_id = ?", "after_seq >= ?"];
|
|
522
|
+
const params = [narrativeId, opts.afterSeqGte ?? 0];
|
|
523
|
+
if (opts.afterSeqLt !== undefined) {
|
|
524
|
+
where.push("after_seq < ?");
|
|
525
|
+
params.push(opts.afterSeqLt);
|
|
526
|
+
}
|
|
527
|
+
if (opts.after) {
|
|
528
|
+
// Row-value comparison against the exact tuple the ORDER BY uses, so the
|
|
529
|
+
// next page starts where the last stopped — no gap, no repeat. after_seq
|
|
530
|
+
// alone can't do this: a burst shares one anchor.
|
|
531
|
+
where.push("(after_seq, started_at, dedup_key) > (?, ?, ?)");
|
|
532
|
+
params.push(opts.after.afterSeq, opts.after.startedAt, opts.after.dedupKey);
|
|
533
|
+
}
|
|
534
|
+
let sql = `SELECT * FROM tool_uses
|
|
535
|
+
WHERE ${where.join(" AND ")}
|
|
536
|
+
ORDER BY after_seq, started_at, dedup_key`;
|
|
537
|
+
if (opts.limit !== undefined) {
|
|
538
|
+
sql += " LIMIT ?";
|
|
539
|
+
params.push(opts.limit);
|
|
540
|
+
}
|
|
541
|
+
return db.prepare(sql).all(...params);
|
|
542
|
+
}
|
|
543
|
+
/** Sync query: rows written or *mutated* since the client's watermark, in rev
|
|
544
|
+
* order (rides tool_uses_sync). The caller's next watermark is the last row's
|
|
545
|
+
* `rev` — never `MAX(rev)` over the narrative, which is only correct for an
|
|
546
|
+
* untruncated read. Display order is the caller's job: sort by
|
|
547
|
+
* (after_seq, started_at), never by rev. */
|
|
548
|
+
/** One row, whole, by the identity the client already holds.
|
|
549
|
+
*
|
|
550
|
+
* The list reads excerpt, because a page of verbatim rows is megabytes. This
|
|
551
|
+
* is the other half of that bargain: when the operator opens a row, the client
|
|
552
|
+
* asks for that one row in full. Paying for a payload at the moment someone
|
|
553
|
+
* wants to read it is a different trade from paying for every payload in case
|
|
554
|
+
* someone might (claude-n1izig04:73). */
|
|
555
|
+
export function readToolUse(db, narrativeId, dedupKey) {
|
|
556
|
+
const row = db
|
|
557
|
+
.prepare("SELECT * FROM tool_uses WHERE narrative_id = ? AND dedup_key = ?")
|
|
558
|
+
.get(narrativeId, dedupKey);
|
|
559
|
+
return row ?? null;
|
|
560
|
+
}
|
|
561
|
+
export function readToolUsesSince(db, narrativeId, opts = {}) {
|
|
562
|
+
let sql = `SELECT * FROM tool_uses
|
|
563
|
+
WHERE narrative_id = ? AND rev > ?
|
|
564
|
+
ORDER BY rev`;
|
|
565
|
+
const params = [narrativeId, opts.sinceRev ?? 0];
|
|
566
|
+
if (opts.limit !== undefined) {
|
|
567
|
+
sql += " LIMIT ?";
|
|
568
|
+
params.push(opts.limit);
|
|
569
|
+
}
|
|
570
|
+
return db.prepare(sql).all(...params);
|
|
571
|
+
}
|
|
572
|
+
/** Highest rev currently stored for a narrative.
|
|
573
|
+
*
|
|
574
|
+
* **Not a client watermark**, despite what this docstring used to say
|
|
575
|
+
* (claude-8fkbabsx:19). A client's watermark is the highest rev it has
|
|
576
|
+
* actually *applied*: this number is only equal to that after an unwindowed,
|
|
577
|
+
* untruncated catch-up, and silently ahead of it in every other case — which
|
|
578
|
+
* is exactly the row-skipping bug the read path was split to prevent. Nothing
|
|
579
|
+
* in the daemon calls this today; it survives as a test helper and as the
|
|
580
|
+
* honest answer to "what is the true max", which is a different question from
|
|
581
|
+
* "what has this client seen". */
|
|
582
|
+
export function maxToolUseRev(db, narrativeId) {
|
|
583
|
+
const row = db
|
|
584
|
+
.prepare("SELECT COALESCE(MAX(rev), 0) AS max_rev FROM tool_uses WHERE narrative_id = ?")
|
|
585
|
+
.get(narrativeId);
|
|
586
|
+
return row.max_rev;
|
|
587
|
+
}
|
|
588
|
+
/** How far a settle may reach back from the call's own start to find its row.
|
|
589
|
+
* The `PreToolUse` hook runs before the call reaches the daemon, so the row's
|
|
590
|
+
* `started_at` is a little *older* than the timestamp the daemon stamps; the
|
|
591
|
+
* forward slack covers a hook that lost a race to the socket. Anything
|
|
592
|
+
* outside this window is a different call. */
|
|
593
|
+
const SETTLE_LOOKBACK_MS = 15_000;
|
|
594
|
+
const SETTLE_LOOKAHEAD_MS = 5_000;
|
|
595
|
+
/** Correct the row for a call the daemon served itself, using the duration it
|
|
596
|
+
* actually took.
|
|
597
|
+
*
|
|
598
|
+
* Hook timing is a vendor's business and it is not always the truth. Claude
|
|
599
|
+
* Code moves a tool call to the background at 120s and fires `PostToolUse`
|
|
600
|
+
* right then, so every `listen` that outlives two minutes is recorded as
|
|
601
|
+
* having completed in exactly 120.0s while it is still blocking — a row that
|
|
602
|
+
* is wrong in both directions, `pending` while the agent may already be idle
|
|
603
|
+
* and `completed` for however long it really keeps waiting
|
|
604
|
+
* (claude-n1izig04:33/:35).
|
|
605
|
+
*
|
|
606
|
+
* For its own MCP tools the daemon does not have to take the hook's word: it
|
|
607
|
+
* resolves the call, so it knows when the call ended. This writes that back.
|
|
608
|
+
*
|
|
609
|
+
* Identification is positional rather than by `dedup_key`, which the daemon
|
|
610
|
+
* never sees — the newest row of that name inside a window around the call's
|
|
611
|
+
* start. An agent blocked in `listen` cannot issue another, so "the newest
|
|
612
|
+
* listen row for this narrative" is unambiguous in the case that matters.
|
|
613
|
+
*
|
|
614
|
+
* Only ever *extends* a recorded duration. A shorter answer means this is not
|
|
615
|
+
* the row we served (or the hook already told the truth), and a settle that
|
|
616
|
+
* could shrink a duration would let a stray call rewrite a finished one.
|
|
617
|
+
* Returns the updated row, or null when there was nothing to correct. */
|
|
618
|
+
export function settleDaemonCall(db, narrativeId, name, startedAtHint, endedAt) {
|
|
619
|
+
const txn = db.transaction(() => {
|
|
620
|
+
const row = db
|
|
621
|
+
.prepare(`SELECT * FROM tool_uses
|
|
622
|
+
WHERE narrative_id = ? AND name = ? AND started_at BETWEEN ? AND ?
|
|
623
|
+
ORDER BY started_at DESC LIMIT 1`)
|
|
624
|
+
.get(narrativeId, name, startedAtHint - SETTLE_LOOKBACK_MS, startedAtHint + SETTLE_LOOKAHEAD_MS);
|
|
625
|
+
if (!row)
|
|
626
|
+
return null;
|
|
627
|
+
const duration = endedAt - row.started_at;
|
|
628
|
+
if (duration <= 0)
|
|
629
|
+
return null;
|
|
630
|
+
if (row.duration_ms !== null && row.duration_ms >= duration)
|
|
631
|
+
return null;
|
|
632
|
+
// A call the daemon answered did not fail, so `pending` becomes
|
|
633
|
+
// `completed`. A row already marked `failed`/`denied` keeps that verdict —
|
|
634
|
+
// whatever it describes, this settle is not evidence against it.
|
|
635
|
+
const status = row.status === "failed" || row.status === "denied" ? row.status : "completed";
|
|
636
|
+
const revRow = db
|
|
637
|
+
.prepare("SELECT COALESCE(MAX(rev), 0) AS max_rev FROM tool_uses WHERE narrative_id = ?")
|
|
638
|
+
.get(narrativeId);
|
|
639
|
+
db.prepare(`UPDATE tool_uses SET duration_ms = ?, status = ?, rev = ?
|
|
640
|
+
WHERE narrative_id = ? AND dedup_key = ?`).run(duration, status, revRow.max_rev + 1, narrativeId, row.dedup_key);
|
|
641
|
+
return db
|
|
642
|
+
.prepare("SELECT * FROM tool_uses WHERE narrative_id = ? AND dedup_key = ?")
|
|
643
|
+
.get(narrativeId, row.dedup_key);
|
|
644
|
+
});
|
|
645
|
+
return txn();
|
|
646
|
+
}
|
|
647
|
+
//# sourceMappingURL=tool-uses.js.map
|