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,301 @@
1
+ import { after, before, describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createTestDb } from "./helpers.js";
7
+ import { recordSessionId, getSessionIds, sumCostReadings, backfillCostLedger, ledgerTotal } from "../daemon/server.js";
8
+ import { getAgentAdapter } from "../agents.js";
9
+ import { priceMessage } from "../cost/pricing.js";
10
+ function seed(db, id, opts = {}) {
11
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, created_at, status, agent_session_id, session_ids)
12
+ VALUES (?, 'brane-test', 'claude', 'worker', 0, 'active', ?, ?)`).run(id, opts.agent_session_id ?? null, opts.session_ids ?? null);
13
+ }
14
+ function storedIds(db, id) {
15
+ return db.prepare("SELECT session_ids FROM narratives WHERE id = ?").get(id).session_ids;
16
+ }
17
+ describe("recordSessionId", () => {
18
+ it("appends the first session as a JSON array", () => {
19
+ const db = createTestDb();
20
+ seed(db, "claude-a");
21
+ recordSessionId(db, "claude-a", "sess-1");
22
+ assert.deepEqual(JSON.parse(storedIds(db, "claude-a")), ["sess-1"]);
23
+ });
24
+ it("accumulates distinct sessions in order, deduping repeats", () => {
25
+ const db = createTestDb();
26
+ seed(db, "claude-a", { session_ids: '["sess-1"]' });
27
+ recordSessionId(db, "claude-a", "sess-2");
28
+ recordSessionId(db, "claude-a", "sess-1"); // dup — ignored
29
+ recordSessionId(db, "claude-a", "sess-3");
30
+ assert.deepEqual(JSON.parse(storedIds(db, "claude-a")), ["sess-1", "sess-2", "sess-3"]);
31
+ });
32
+ it("is a no-op for an empty session id or unknown narrative", () => {
33
+ const db = createTestDb();
34
+ seed(db, "claude-a", { session_ids: '["sess-1"]' });
35
+ recordSessionId(db, "claude-a", undefined);
36
+ recordSessionId(db, "claude-a", "");
37
+ recordSessionId(db, "claude-missing", "sess-x"); // no row → no throw
38
+ assert.deepEqual(JSON.parse(storedIds(db, "claude-a")), ["sess-1"]);
39
+ });
40
+ it("recovers from a malformed session_ids value by starting fresh", () => {
41
+ const db = createTestDb();
42
+ seed(db, "claude-a", { session_ids: "{not json" });
43
+ recordSessionId(db, "claude-a", "sess-1");
44
+ assert.deepEqual(JSON.parse(storedIds(db, "claude-a")), ["sess-1"]);
45
+ });
46
+ });
47
+ describe("session_ids migration backfill", () => {
48
+ // Pins the exact backfill SQL from openDatabase(): seed the set from the
49
+ // current session for rows that predate the column, and never clobber a set
50
+ // that's already populated.
51
+ const BACKFILL = `UPDATE narratives SET session_ids = '["' || agent_session_id || '"]'
52
+ WHERE session_ids IS NULL AND agent_session_id IS NOT NULL`;
53
+ it("seeds session_ids from agent_session_id for a pre-column row", () => {
54
+ const db = createTestDb();
55
+ seed(db, "claude-a", { agent_session_id: "550e8400-e29b-41d4-a716-446655440000", session_ids: null });
56
+ db.exec(BACKFILL);
57
+ assert.deepEqual(JSON.parse(storedIds(db, "claude-a")), ["550e8400-e29b-41d4-a716-446655440000"]);
58
+ });
59
+ it("leaves an already-populated set untouched and skips session-less rows", () => {
60
+ const db = createTestDb();
61
+ seed(db, "claude-has-set", { agent_session_id: "sess-cur", session_ids: '["sess-old","sess-cur"]' });
62
+ seed(db, "claude-no-session"); // both null
63
+ db.exec(BACKFILL);
64
+ assert.deepEqual(JSON.parse(storedIds(db, "claude-has-set")), ["sess-old", "sess-cur"]);
65
+ assert.equal(storedIds(db, "claude-no-session"), null);
66
+ });
67
+ });
68
+ describe("getSessionIds", () => {
69
+ it("returns the stored set unioned with the current agent_session_id", () => {
70
+ const db = createTestDb();
71
+ seed(db, "claude-a", { agent_session_id: "sess-2", session_ids: '["sess-1","sess-2"]' });
72
+ assert.deepEqual(getSessionIds(db, "claude-a"), ["sess-1", "sess-2"]);
73
+ });
74
+ it("appends the current session when the set somehow missed it", () => {
75
+ const db = createTestDb();
76
+ seed(db, "claude-a", { agent_session_id: "sess-3", session_ids: '["sess-1","sess-2"]' });
77
+ assert.deepEqual(getSessionIds(db, "claude-a"), ["sess-1", "sess-2", "sess-3"]);
78
+ });
79
+ it("falls back to agent_session_id for a legacy row with no set", () => {
80
+ const db = createTestDb();
81
+ seed(db, "claude-a", { agent_session_id: "sess-legacy", session_ids: null });
82
+ assert.deepEqual(getSessionIds(db, "claude-a"), ["sess-legacy"]);
83
+ });
84
+ it("returns [] for a narrative with neither set nor current session, and for unknown ids", () => {
85
+ const db = createTestDb();
86
+ seed(db, "claude-a");
87
+ assert.deepEqual(getSessionIds(db, "claude-a"), []);
88
+ assert.deepEqual(getSessionIds(db, "claude-missing"), []);
89
+ });
90
+ });
91
+ describe("sumCostReadings", () => {
92
+ const r = (amount, unit = "USD", tokens) => tokens ? { amount, unit, tokens } : { amount, unit };
93
+ it("sums amounts across sessions of the same unit", () => {
94
+ const total = sumCostReadings([r(1.5), r(2.25), r(0.25)]);
95
+ assert.deepEqual(total, { amount: 4, unit: "USD" });
96
+ });
97
+ it("sums token breakdowns component-wise", () => {
98
+ const total = sumCostReadings([
99
+ r(1, "USD", { input: 10, output: 5, cacheRead: 2, cacheWrite: 1 }),
100
+ r(2, "USD", { input: 20, output: 7, cacheRead: 3, cacheWrite: 4 }),
101
+ ]);
102
+ assert.deepEqual(total, {
103
+ amount: 3,
104
+ unit: "USD",
105
+ tokens: { input: 30, output: 12, cacheRead: 5, cacheWrite: 5 },
106
+ });
107
+ });
108
+ it("skips null readings (transcript gone) and still totals the rest", () => {
109
+ const total = sumCostReadings([r(1), null, r(3), null]);
110
+ assert.deepEqual(total, { amount: 4, unit: "USD" });
111
+ });
112
+ it("returns null when there are no usable readings", () => {
113
+ assert.equal(sumCostReadings([]), null);
114
+ assert.equal(sumCostReadings([null, null]), null);
115
+ });
116
+ it("never adds across differing units — keeps the first, skips mismatches", () => {
117
+ const total = sumCostReadings([r(1, "USD"), r(1000, "AIC"), r(2, "USD")]);
118
+ assert.deepEqual(total, { amount: 3, unit: "USD" });
119
+ });
120
+ });
121
+ // The claude adapter reads real transcripts under ~/.claude/projects/, and the
122
+ // pricing table overlays an optional ~/.brane/pricing.json — so both suites
123
+ // below sandbox HOME to a temp dir (same pattern as model-resolution.test.ts)
124
+ // and fabricate session JSONLs there.
125
+ describe("claude usage reading and pricing", () => {
126
+ let origHome;
127
+ let homeDir;
128
+ const FOLD = "/work/proj-cost";
129
+ const SESSION_A = "aaaaaaaa-0000-0000-0000-000000000001";
130
+ const SESSION_B = "bbbbbbbb-0000-0000-0000-000000000002";
131
+ const SESSION_GONE = "cccccccc-0000-0000-0000-000000000003";
132
+ const usage = (input, output) => ({
133
+ input,
134
+ output,
135
+ cacheRead: 0,
136
+ cacheWrite5m: 0,
137
+ cacheWrite1h: 0,
138
+ });
139
+ function usageLine(id, model, u) {
140
+ return JSON.stringify({
141
+ type: "assistant",
142
+ message: {
143
+ ...(id ? { id } : {}),
144
+ model,
145
+ role: "assistant",
146
+ usage: {
147
+ input_tokens: u.input,
148
+ output_tokens: u.output,
149
+ cache_read_input_tokens: u.cacheRead,
150
+ cache_creation: {
151
+ ephemeral_5m_input_tokens: u.cacheWrite5m,
152
+ ephemeral_1h_input_tokens: u.cacheWrite1h,
153
+ },
154
+ },
155
+ },
156
+ });
157
+ }
158
+ // Session A: two id-bearing messages plus one id-less synthetic record.
159
+ // Session B: fork of A — A's id-bearing lines copied verbatim (same ids),
160
+ // then two genuinely new messages, one of them streamed twice (duplicate id
161
+ // within the file).
162
+ const A1 = { id: "msg_a1", model: "claude-opus-4-8", u: usage(1000, 500) };
163
+ const A2 = { id: "msg_a2", model: "claude-opus-4-8", u: usage(2000, 700) };
164
+ const A_SYNTH = { id: undefined, model: "<synthetic>", u: usage(10, 5) };
165
+ const B1 = { id: "msg_b1", model: "claude-fable-5", u: usage(3000, 900) };
166
+ const B2 = { id: "msg_b2", model: "claude-fable-5", u: usage(4000, 1100) };
167
+ const cost = (m) => priceMessage(m.model, m.u);
168
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
169
+ const costA = round4(cost(A1) + cost(A2) + cost(A_SYNTH));
170
+ const costAB = round4(cost(A1) + cost(A2) + cost(A_SYNTH) + cost(B1) + cost(B2));
171
+ before(() => {
172
+ origHome = process.env.HOME;
173
+ homeDir = mkdtempSync(join(tmpdir(), "brane-cost-home-"));
174
+ process.env.HOME = homeDir;
175
+ const projectDir = join(homeDir, ".claude", "projects", FOLD.replace(/\//g, "-"));
176
+ mkdirSync(projectDir, { recursive: true });
177
+ const aLines = [
178
+ usageLine(A1.id, A1.model, A1.u),
179
+ usageLine(A2.id, A2.model, A2.u),
180
+ usageLine(A_SYNTH.id, A_SYNTH.model, A_SYNTH.u),
181
+ ];
182
+ const bLines = [
183
+ usageLine(A1.id, A1.model, A1.u), // fork-copied history, same ids
184
+ usageLine(A2.id, A2.model, A2.u),
185
+ usageLine(B1.id, B1.model, B1.u),
186
+ usageLine(B1.id, B1.model, B1.u), // streamed duplicate within the file
187
+ usageLine(B2.id, B2.model, B2.u),
188
+ ];
189
+ writeFileSync(join(projectDir, `${SESSION_A}.jsonl`), aLines.join("\n") + "\n");
190
+ writeFileSync(join(projectDir, `${SESSION_B}.jsonl`), bLines.join("\n") + "\n");
191
+ });
192
+ after(() => {
193
+ if (origHome === undefined)
194
+ delete process.env.HOME;
195
+ else
196
+ process.env.HOME = origHome;
197
+ });
198
+ describe("readUsage / readUsageMany fork dedup", () => {
199
+ const claude = getAgentAdapter("claude");
200
+ it("readUsage prices a single session, counting id-less records and deduping streamed duplicates", () => {
201
+ const a = claude.readUsage({ sessionId: SESSION_A, foldPath: FOLD });
202
+ assert.equal(a?.amount, costA);
203
+ assert.deepEqual(a?.tokens, {
204
+ input: A1.u.input + A2.u.input + A_SYNTH.u.input,
205
+ output: A1.u.output + A2.u.output + A_SYNTH.u.output,
206
+ cacheRead: 0,
207
+ cacheWrite: 0,
208
+ });
209
+ const b = claude.readUsage({ sessionId: SESSION_B, foldPath: FOLD });
210
+ assert.equal(b?.amount, round4(cost(A1) + cost(A2) + cost(B1) + cost(B2)));
211
+ });
212
+ it("readUsageMany counts fork-copied messages once: total = cost(A) + cost(B new only)", () => {
213
+ const total = claude.readUsageMany({ sessionIds: [SESSION_A, SESSION_B], foldPath: FOLD });
214
+ assert.equal(total?.amount, costAB);
215
+ assert.equal(total?.unit, "USD");
216
+ assert.deepEqual(total?.tokens, {
217
+ input: A1.u.input + A2.u.input + A_SYNTH.u.input + B1.u.input + B2.u.input,
218
+ output: A1.u.output + A2.u.output + A_SYNTH.u.output + B1.u.output + B2.u.output,
219
+ cacheRead: 0,
220
+ cacheWrite: 0,
221
+ });
222
+ });
223
+ it("repeated sampling is stable (memoized parse, same totals)", () => {
224
+ const first = claude.readUsageMany({ sessionIds: [SESSION_A, SESSION_B], foldPath: FOLD });
225
+ const second = claude.readUsageMany({ sessionIds: [SESSION_A, SESSION_B], foldPath: FOLD });
226
+ assert.deepEqual(second, first);
227
+ });
228
+ it("readUsageEntries keys messages by id, and id-less records by file position", () => {
229
+ const readings = claude.readUsageEntries({ sessionIds: [SESSION_A, SESSION_B, SESSION_GONE], foldPath: FOLD });
230
+ assert.equal(readings?.length, 2);
231
+ const [a, b] = readings;
232
+ assert.equal(a.sessionId, SESSION_A);
233
+ assert.equal(a.unit, "USD");
234
+ assert.deepEqual(a.entries.map((e) => [e.key, e.amount]), [["claude:msg_a1", cost(A1)], ["claude:msg_a2", cost(A2)], [`claude:${SESSION_A}#2`, cost(A_SYNTH)]]);
235
+ // Fork-copied ids repeat verbatim — the ledger's primary key is what
236
+ // collapses them — while the in-file streamed duplicate is already gone.
237
+ assert.deepEqual(b.entries.map((e) => e.key), ["claude:msg_a1", "claude:msg_a2", "claude:msg_b1", "claude:msg_b2"]);
238
+ assert.ok(a.sig && b.sig);
239
+ assert.equal(claude.readUsageEntries({ sessionIds: [SESSION_GONE], foldPath: FOLD }), null);
240
+ });
241
+ it("skips missing session files, and returns null when none are readable", () => {
242
+ const partial = claude.readUsageMany({ sessionIds: [SESSION_A, SESSION_GONE], foldPath: FOLD });
243
+ assert.equal(partial?.amount, costA);
244
+ assert.equal(claude.readUsageMany({ sessionIds: [SESSION_GONE], foldPath: FOLD }), null);
245
+ assert.equal(claude.readUsageMany({ sessionIds: [], foldPath: FOLD }), null);
246
+ });
247
+ });
248
+ describe("backfillCostLedger", () => {
249
+ function seedFold(db, id, createdAt, sessionId, cost) {
250
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, created_at, status, fold_path, agent_session_id, session_ids, cost_amount, cost_unit)
251
+ VALUES (?, 'brane-test', 'claude', 'worker', ?, 'ended', ?, ?, ?, ?, ?)`).run(id, createdAt, FOLD, sessionId, JSON.stringify([sessionId]), cost ?? null, cost == null ? null : "USD");
252
+ }
253
+ it("walks oldest first, so the original claims fork-copied messages before its twin", () => {
254
+ const db = createTestDb();
255
+ // Inserted newest-first to prove the order comes from created_at, not rowid.
256
+ seedFold(db, "claude-twin", 200, SESSION_B);
257
+ seedFold(db, "claude-orig", 100, SESSION_A);
258
+ const dry = backfillCostLedger(db, { dryRun: true });
259
+ assert.equal(dry.scanned, 2);
260
+ assert.deepEqual(dry.samples.map((s) => s.nid), ["claude-orig", "claude-twin"]);
261
+ assert.equal(ledgerTotal(db, "claude-orig"), null, "dry run writes nothing");
262
+ const real = backfillCostLedger(db);
263
+ assert.equal(real.samples.length, 2);
264
+ // The ledger keeps six decimals where the old reader rounded to four.
265
+ assert.equal(round4(ledgerTotal(db, "claude-orig").amount), costA);
266
+ assert.equal(round4(ledgerTotal(db, "claude-twin").amount), round4(cost(B1) + cost(B2)));
267
+ const col = (id) => db.prepare("SELECT cost_amount FROM narratives WHERE id = ?").get(id).cost_amount;
268
+ assert.equal(round4(col("claude-orig")), costA);
269
+ assert.equal(round4(col("claude-twin")), round4(cost(B1) + cost(B2)));
270
+ // Idempotent: nothing new on a second pass.
271
+ const again = backfillCostLedger(db);
272
+ assert.ok(again.samples.every((s) => s.new_rows === 0 && s.before === s.after));
273
+ });
274
+ it("keeps a frozen column that exceeds what the surviving transcripts show", () => {
275
+ const db = createTestDb();
276
+ seedFold(db, "claude-frozen", 100, SESSION_A, 99.5);
277
+ backfillCostLedger(db);
278
+ assert.equal(round4(ledgerTotal(db, "claude-frozen").amount), costA);
279
+ const col = db.prepare("SELECT cost_amount FROM narratives WHERE id = ?").get("claude-frozen").cost_amount;
280
+ assert.equal(col, 99.5);
281
+ });
282
+ });
283
+ describe("priceMessage tiers", () => {
284
+ const oneMTokEach = usage(1e6, 1e6);
285
+ it("prices fable and mythos at $10/$50 — exactly 2× opus for the same usage", () => {
286
+ const opus = priceMessage("claude-opus-4-8", oneMTokEach);
287
+ assert.equal(opus, 30);
288
+ assert.equal(priceMessage("claude-fable-5", oneMTokEach), 2 * opus);
289
+ assert.equal(priceMessage("claude-mythos-5", oneMTokEach), 2 * opus);
290
+ });
291
+ it("keeps cache multipliers relative to the fable input price", () => {
292
+ const cacheOnly = { input: 0, output: 0, cacheRead: 1e6, cacheWrite5m: 1e6, cacheWrite1h: 1e6 };
293
+ // 0.1× + 1.25× + 2× of the $10 input price.
294
+ assert.equal(priceMessage("claude-fable-5", cacheOnly), 1 + 12.5 + 20);
295
+ });
296
+ it("prices unknown models at the most expensive known tier (fable)", () => {
297
+ assert.equal(priceMessage("claude-newthing-9", oneMTokEach), 60);
298
+ });
299
+ });
300
+ });
301
+ //# sourceMappingURL=cost-sessions.test.js.map
@@ -0,0 +1,289 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import Database from "better-sqlite3";
4
+ import { mkdtempSync, mkdirSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createTestDb } from "./helpers.js";
8
+ import { readToolUseWindow } from "../db/tool-uses.js";
9
+ import { toolUsesFromCrushParts, resolveCrushSessionIds, pollCrushToolLog, newCrushLogCursor, crushDbPath, crushLogSignature, } from "../crush-tool-log.js";
10
+ /** crush's schema, copied from a live `~/dev/crush/.crush/crush.db` — including
11
+ * the comments that claim milliseconds on columns that hold seconds. */
12
+ const CRUSH_SCHEMA = `
13
+ CREATE TABLE sessions (
14
+ id TEXT PRIMARY KEY,
15
+ parent_session_id TEXT,
16
+ title TEXT NOT NULL,
17
+ message_count INTEGER NOT NULL DEFAULT 0,
18
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
19
+ completion_tokens INTEGER NOT NULL DEFAULT 0,
20
+ cost REAL NOT NULL DEFAULT 0.0,
21
+ updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
22
+ created_at INTEGER NOT NULL -- Unix timestamp in milliseconds
23
+ );
24
+ CREATE TABLE messages (
25
+ id TEXT PRIMARY KEY,
26
+ session_id TEXT NOT NULL,
27
+ role TEXT NOT NULL,
28
+ parts TEXT NOT NULL DEFAULT '[]',
29
+ model TEXT,
30
+ created_at INTEGER NOT NULL,
31
+ updated_at INTEGER NOT NULL,
32
+ finished_at INTEGER,
33
+ provider TEXT
34
+ );
35
+ `;
36
+ /** Real part shapes, from the live database. Note the double encoding: `input`
37
+ * and `metadata` are JSON strings inside a JSON document. */
38
+ const TOOL_CALL_PART = JSON.stringify([
39
+ {
40
+ type: "tool_call",
41
+ data: {
42
+ id: "bash_88",
43
+ name: "bash",
44
+ input: '{"command": "sleep 25"}',
45
+ provider_executed: false,
46
+ finished: false,
47
+ },
48
+ },
49
+ ]);
50
+ const TOOL_RESULT_PART = JSON.stringify([
51
+ {
52
+ type: "tool_result",
53
+ data: {
54
+ tool_call_id: "bash_88",
55
+ name: "bash",
56
+ content: "done",
57
+ is_error: false,
58
+ metadata: '{"start_time":1786903334560,"end_time":1786903335561,"description":"sleep"}',
59
+ },
60
+ },
61
+ ]);
62
+ function crushFold() {
63
+ const fold = mkdtempSync(join(tmpdir(), "brane-crush-"));
64
+ mkdirSync(join(fold, ".crush"), { recursive: true });
65
+ return fold;
66
+ }
67
+ function seedCrush(fold) {
68
+ const crush = new Database(crushDbPath(fold));
69
+ crush.exec(CRUSH_SCHEMA);
70
+ return crush;
71
+ }
72
+ function addSession(crush, id, createdAt, parent) {
73
+ crush
74
+ .prepare("INSERT INTO sessions (id, parent_session_id, title, updated_at, created_at) VALUES (?, ?, 'x', ?, ?)")
75
+ .run(id, parent ?? null, createdAt, createdAt);
76
+ }
77
+ function addMessage(crush, id, sessionId, parts, at) {
78
+ crush
79
+ .prepare("INSERT INTO messages (id, session_id, role, parts, created_at, updated_at) VALUES (?, ?, 'assistant', ?, ?, ?)")
80
+ .run(id, sessionId, parts, at, at);
81
+ }
82
+ function seedNarrative(db, fold, createdAtMs) {
83
+ const nid = "crush-test0001";
84
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at) VALUES (?, 'brane-test', 'crush', 'worker', 'active', ?, ?)").run(nid, fold, createdAtMs);
85
+ return nid;
86
+ }
87
+ describe("toolUsesFromCrushParts", () => {
88
+ // crush persists the tool_call part before the tool runs, which is the whole
89
+ // reason a session-log tail can stand in for a PreToolUse hook.
90
+ it("reads a call as pending and un-double-encodes its input", () => {
91
+ const [call] = toolUsesFromCrushParts(TOOL_CALL_PART, {});
92
+ assert.equal(call.dedupKey, "bash_88");
93
+ assert.equal(call.name, "bash");
94
+ assert.equal(call.status, "pending");
95
+ assert.deepEqual(call.input, { command: "sleep 25" }, "input arrives as a JSON string");
96
+ assert.equal(call.source, "session_log");
97
+ assert.equal(call.agentType, "crush");
98
+ });
99
+ // `finished` says the call returned, not that it worked. The outcome lives in
100
+ // the tool_result part, which may not be written yet — so reading `finished`
101
+ // as success would mark failed calls completed.
102
+ it("keeps a finished call pending until its result part arrives", () => {
103
+ const finished = TOOL_CALL_PART.replace('"finished":false', '"finished":true');
104
+ assert.equal(toolUsesFromCrushParts(finished, {})[0].status, "pending");
105
+ });
106
+ it("completes the call from its result, with a duration in the right unit", () => {
107
+ const [result] = toolUsesFromCrushParts(TOOL_RESULT_PART, {});
108
+ assert.equal(result.dedupKey, "bash_88", "same key as the call — one row, not two");
109
+ assert.equal(result.status, "completed");
110
+ assert.equal(result.output, "done");
111
+ // metadata's start_time/end_time are milliseconds, unlike the columns.
112
+ assert.equal(result.durationMs, 1_001);
113
+ assert.equal(result.startedAt, 1_786_903_334_560);
114
+ });
115
+ it("reads is_error as a failure", () => {
116
+ const failed = TOOL_RESULT_PART.replace('"is_error":false', '"is_error":true');
117
+ assert.equal(toolUsesFromCrushParts(failed, {})[0].status, "failed");
118
+ });
119
+ it("survives junk parts rather than throwing inside a poll", () => {
120
+ assert.deepEqual(toolUsesFromCrushParts("not json", {}), []);
121
+ assert.deepEqual(toolUsesFromCrushParts('{"not":"an array"}', {}), []);
122
+ assert.deepEqual(toolUsesFromCrushParts('[{"type":"text","data":{"text":"hi"}}]', {}), []);
123
+ assert.deepEqual(toolUsesFromCrushParts('[{"type":"tool_call","data":{}}]', {}), []);
124
+ // A result with no metadata still completes; it just has no duration.
125
+ const [bare] = toolUsesFromCrushParts('[{"type":"tool_result","data":{"tool_call_id":"x_1","name":"x","content":"ok"}}]', {});
126
+ assert.equal(bare.status, "completed");
127
+ assert.equal(bare.durationMs, undefined);
128
+ });
129
+ });
130
+ describe("resolveCrushSessionIds", () => {
131
+ it("prefers the ids brane recorded over any heuristic", () => {
132
+ const fold = crushFold();
133
+ const crush = seedCrush(fold);
134
+ addSession(crush, "recorded-one", 1_000);
135
+ addSession(crush, "newer-other", 9_999);
136
+ assert.deepEqual(resolveCrushSessionIds(crush, { recorded: ["recorded-one"], narrativeCreatedAtMs: 1_000_000 }), ["recorded-one"]);
137
+ });
138
+ // The fallback compares against crush's clock, which counts seconds — the
139
+ // schema comment says milliseconds and the live rows do not. A narrative's
140
+ // created_at is milliseconds, so an unconverted comparison matches nothing,
141
+ // forever, and reads exactly like a session that made no tool calls.
142
+ it("compares against crush's seconds, not brane's milliseconds", () => {
143
+ const fold = crushFold();
144
+ const crush = seedCrush(fold);
145
+ const spawnedAtMs = 1_786_900_000_000;
146
+ addSession(crush, "before", Math.floor(spawnedAtMs / 1000) - 60);
147
+ addSession(crush, "after", Math.floor(spawnedAtMs / 1000) + 60);
148
+ assert.deepEqual(resolveCrushSessionIds(crush, { narrativeCreatedAtMs: spawnedAtMs }), ["after"]);
149
+ });
150
+ // The exclusion has to happen in the query. Asking for the newest row and
151
+ // then discarding it if it turns out to be bookkeeping reports "no session"
152
+ // while a perfectly good one sits directly underneath it — and the poller
153
+ // then captures nothing at all until the next real session is created.
154
+ it("skips past bookkeeping rows to the newest real session", () => {
155
+ const fold = crushFold();
156
+ const crush = seedCrush(fold);
157
+ addSession(crush, "real-session", 1_000);
158
+ // Both shapes are live in the operator's own crush database today. Give
159
+ // them null parents: sub-agent rows are parented in the logs seen so far,
160
+ // but nothing guarantees every kind of synthetic row is.
161
+ addSession(crush, "title-real-session", 2_000);
162
+ addSession(crush, "a0ffd9e8$$agentic_fetch_68", 3_000);
163
+ assert.deepEqual(resolveCrushSessionIds(crush, { narrativeCreatedAtMs: 0 }), ["real-session"]);
164
+ assert.deepEqual(resolveCrushSessionIds(crush, { recorded: ["title-x", "real-session"], narrativeCreatedAtMs: 0 }), ["real-session"]);
165
+ });
166
+ });
167
+ describe("pollCrushToolLog", () => {
168
+ it("records a running call, then settles it when the result lands", () => {
169
+ const fold = crushFold();
170
+ const crush = seedCrush(fold);
171
+ const db = createTestDb();
172
+ const nid = seedNarrative(db, fold, 1_000_000);
173
+ addSession(crush, "s1", 1_000);
174
+ addMessage(crush, "m1", "s1", TOOL_CALL_PART, 1_100);
175
+ const cursor = newCrushLogCursor();
176
+ const first = pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor);
177
+ assert.equal(first.length, 1);
178
+ assert.equal(first[0].status, "pending");
179
+ addMessage(crush, "m2", "s1", TOOL_RESULT_PART, 1_101);
180
+ const second = pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor);
181
+ assert.equal(second.length, 1);
182
+ assert.equal(second[0].status, "completed");
183
+ assert.equal(second[0].duration_ms, 1_001);
184
+ const rows = readToolUseWindow(db, nid);
185
+ assert.equal(rows.length, 1, "the call and its result are one row, not two");
186
+ assert.equal(rows[0].source, "session_log");
187
+ });
188
+ // Every write bumps rev, and rev is the client's sync cursor — so re-writing
189
+ // a settled row would re-push it to every connected client on every poll,
190
+ // forever.
191
+ it("writes nothing when nothing changed", () => {
192
+ const fold = crushFold();
193
+ const crush = seedCrush(fold);
194
+ const db = createTestDb();
195
+ const nid = seedNarrative(db, fold, 1_000_000);
196
+ addSession(crush, "s1", 1_000);
197
+ addMessage(crush, "m1", "s1", TOOL_CALL_PART, 1_100);
198
+ addMessage(crush, "m2", "s1", TOOL_RESULT_PART, 1_100);
199
+ const cursor = newCrushLogCursor();
200
+ assert.equal(pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor).length, 2, "the call and its completion are both new the first time");
201
+ for (let i = 0; i < 3; i++) {
202
+ assert.deepEqual(pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor), []);
203
+ }
204
+ assert.equal(readToolUseWindow(db, nid)[0].rev, 2, "rev stopped moving once the row settled");
205
+ });
206
+ // crush timestamps have one-second resolution, so a strict `>` cursor would
207
+ // skip anything written in the same second as the last message read.
208
+ it("does not skip a message written in the same second as the cursor", () => {
209
+ const fold = crushFold();
210
+ const crush = seedCrush(fold);
211
+ const db = createTestDb();
212
+ const nid = seedNarrative(db, fold, 1_000_000);
213
+ addSession(crush, "s1", 1_000);
214
+ addMessage(crush, "m1", "s1", TOOL_CALL_PART, 1_100);
215
+ const cursor = newCrushLogCursor();
216
+ pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor);
217
+ // Same second, different message — the very next tool call in a fast turn.
218
+ addMessage(crush, "m2", "s1", TOOL_CALL_PART.replace(/bash_88/g, "bash_89"), 1_100);
219
+ const next = pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor);
220
+ assert.equal(next.length, 1);
221
+ assert.equal(next[0].dedup_key, "bash_89");
222
+ });
223
+ it("captures a sub-agent's calls and nests them under the call that spawned it", () => {
224
+ const fold = crushFold();
225
+ const crush = seedCrush(fold);
226
+ const db = createTestDb();
227
+ const nid = seedNarrative(db, fold, 1_000_000);
228
+ addSession(crush, "s1", 1_000);
229
+ // crush encodes the spawning tool call in a sub-agent session's own id.
230
+ addSession(crush, "msg7$$agentic_fetch_68", 1_050, "s1");
231
+ addSession(crush, "title-s1", 1_060, "s1");
232
+ addMessage(crush, "m1", "msg7$$agentic_fetch_68", TOOL_CALL_PART, 1_100);
233
+ addMessage(crush, "m2", "title-s1", TOOL_CALL_PART.replace(/bash_88/g, "title_1"), 1_100);
234
+ const written = pollCrushToolLog(db, nid, { foldPath: fold, sessionIds: ["s1"], narrativeCreatedAtMs: 0 }, newCrushLogCursor());
235
+ assert.equal(written.length, 1, "the title-generation session is not a conversation");
236
+ assert.equal(written[0].parent_id, "agentic_fetch_68");
237
+ });
238
+ // This poller ticks every couple of seconds, so almost all of its work is
239
+ // avoiding work: an unchanged log is never opened at all.
240
+ it("does not open the log when nothing has changed", () => {
241
+ const fold = crushFold();
242
+ const crush = seedCrush(fold);
243
+ const db = createTestDb();
244
+ const nid = seedNarrative(db, fold, 1_000_000);
245
+ addSession(crush, "s1", 1_000);
246
+ addMessage(crush, "m1", "s1", TOOL_CALL_PART, 1_100);
247
+ const cursor = newCrushLogCursor();
248
+ pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor);
249
+ const signature = cursor.signature;
250
+ assert.ok(signature, "a log that was read leaves a signature to compare against");
251
+ // Break the log so that opening it would throw. A poll that still returns
252
+ // cleanly is a poll that never opened it.
253
+ const table = new Database(crushDbPath(fold));
254
+ table.exec("ALTER TABLE messages RENAME TO messages_moved");
255
+ table.close();
256
+ // Restore the signature: the rename changed the file, and the point of the
257
+ // test is the unchanged case.
258
+ cursor.signature = crushLogSignature(fold);
259
+ assert.deepEqual(pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor), []);
260
+ });
261
+ it("notices a write that lands only in the WAL sibling", () => {
262
+ const fold = crushFold();
263
+ const crush = seedCrush(fold);
264
+ crush.pragma("journal_mode = WAL");
265
+ const db = createTestDb();
266
+ const nid = seedNarrative(db, fold, 1_000_000);
267
+ addSession(crush, "s1", 1_000);
268
+ const cursor = newCrushLogCursor();
269
+ pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor);
270
+ // In WAL mode this write lands in <db>-wal and may not move the main
271
+ // file's mtime at all — a gate that stats only the database would go blind
272
+ // during exactly the burst of activity it exists to catch.
273
+ addMessage(crush, "m1", "s1", TOOL_CALL_PART, 1_100);
274
+ const written = pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, cursor);
275
+ assert.equal(written.length, 1);
276
+ assert.equal(written[0].status, "pending");
277
+ });
278
+ it("degrades to silence when there is no crush log, or it is unreadable", () => {
279
+ const db = createTestDb();
280
+ const bare = mkdtempSync(join(tmpdir(), "brane-crush-"));
281
+ const nid = seedNarrative(db, bare, 1_000_000);
282
+ assert.deepEqual(pollCrushToolLog(db, nid, { foldPath: bare, narrativeCreatedAtMs: 0 }, newCrushLogCursor()), []);
283
+ // A file that exists but is not a crush database at all.
284
+ const fold = crushFold();
285
+ new Database(crushDbPath(fold)).exec("CREATE TABLE unrelated (x TEXT)");
286
+ assert.deepEqual(pollCrushToolLog(db, nid, { foldPath: fold, narrativeCreatedAtMs: 0 }, newCrushLogCursor()), []);
287
+ });
288
+ });
289
+ //# sourceMappingURL=crush-tool-log.test.js.map