brane 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +79 -27
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
@@ -0,0 +1,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