brane 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +360 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1101 -0
- package/dist/commands/token.js +154 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6353 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +643 -0
- package/dist/db/tokens.js +624 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +84 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +216 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +56 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +299 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +143 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +150 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +128 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +646 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +627 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +104 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +53 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
package/dist/agents.js
ADDED
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
import { readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { priceMessage } from "./cost/pricing.js";
|
|
6
|
+
import { readClaudeLiveSession } from "./claude-live-session.js";
|
|
7
|
+
function num(v) {
|
|
8
|
+
return typeof v === "number" ? v : 0;
|
|
9
|
+
}
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Shared plumbing for harness usage readers (goose / cline / crush)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/** Signature of the files a reading derives from, for memoization. Returns null
|
|
14
|
+
* when the *first* path is missing — that's the store itself, so there is
|
|
15
|
+
* nothing to read; later paths are optional and sign as "-" when absent.
|
|
16
|
+
*
|
|
17
|
+
* For a SQLite store, always pass the `-wal` sidecar alongside the db: under
|
|
18
|
+
* WAL a writer appends to the sidecar without touching the main file's mtime,
|
|
19
|
+
* so signing on the db alone would pin a stale reading for the whole life of
|
|
20
|
+
* the WAL — precisely the case here, since every one of these harnesses is
|
|
21
|
+
* writing while we sample. */
|
|
22
|
+
function fileSignature(paths) {
|
|
23
|
+
let sig = "";
|
|
24
|
+
for (const [i, p] of paths.entries()) {
|
|
25
|
+
try {
|
|
26
|
+
const st = statSync(p);
|
|
27
|
+
sig += `${st.size}:${st.mtimeMs};`;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
if (i === 0)
|
|
31
|
+
return null;
|
|
32
|
+
sig += "-;";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return sig;
|
|
36
|
+
}
|
|
37
|
+
const usageMemo = new Map();
|
|
38
|
+
/** Memoize a reading on its source files' signature, so the daemon's 20s cost
|
|
39
|
+
* sampler re-reads a session store only when it actually changed. A null
|
|
40
|
+
* result is never cached — absence is usually transient (a session dir that
|
|
41
|
+
* hasn't been written yet), and re-checking it costs one stat. */
|
|
42
|
+
function memoized(key, paths, compute) {
|
|
43
|
+
const sig = fileSignature(paths);
|
|
44
|
+
if (sig === null)
|
|
45
|
+
return null;
|
|
46
|
+
const hit = usageMemo.get(key);
|
|
47
|
+
if (hit && hit.sig === sig)
|
|
48
|
+
return hit.value;
|
|
49
|
+
const value = compute();
|
|
50
|
+
if (value === null)
|
|
51
|
+
return null;
|
|
52
|
+
usageMemo.set(key, { sig, value });
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
/** Run `fn` against a read-only connection to `dbPath`, always closing it.
|
|
56
|
+
* Returns null if the database can't be opened or the query throws — a
|
|
57
|
+
* harness store we can't read must degrade to "no cost", never to an
|
|
58
|
+
* exception in the sampler. Read-only is not merely polite: these are live
|
|
59
|
+
* databases owned by other processes, and brane must never write to them. */
|
|
60
|
+
function withReadonlyDb(dbPath, fn) {
|
|
61
|
+
let db;
|
|
62
|
+
try {
|
|
63
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return fn(db);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
try {
|
|
76
|
+
db.close();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Nothing actionable — the connection is being discarded anyway.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Sum per-session cost readings into one cumulative total. Readings share an
|
|
84
|
+
* agent (and thus a unit) since they belong to one narrative; a stray
|
|
85
|
+
* mismatched unit is skipped rather than summed across units. Returns null when
|
|
86
|
+
* no reading is available (every session's transcript gone / unreadable). */
|
|
87
|
+
export function sumCostReadings(readings) {
|
|
88
|
+
let unit;
|
|
89
|
+
let amount = 0;
|
|
90
|
+
let tokens;
|
|
91
|
+
let any = false;
|
|
92
|
+
for (const r of readings) {
|
|
93
|
+
if (!r)
|
|
94
|
+
continue;
|
|
95
|
+
if (unit === undefined)
|
|
96
|
+
unit = r.unit;
|
|
97
|
+
if (r.unit !== unit)
|
|
98
|
+
continue; // never add across differing units
|
|
99
|
+
any = true;
|
|
100
|
+
amount += r.amount;
|
|
101
|
+
if (r.tokens) {
|
|
102
|
+
tokens ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
103
|
+
tokens.input += r.tokens.input;
|
|
104
|
+
tokens.output += r.tokens.output;
|
|
105
|
+
tokens.cacheRead += r.tokens.cacheRead;
|
|
106
|
+
tokens.cacheWrite += r.tokens.cacheWrite;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!any || unit === undefined)
|
|
110
|
+
return null;
|
|
111
|
+
return tokens ? { amount, unit, tokens } : { amount, unit };
|
|
112
|
+
}
|
|
113
|
+
/** Epoch seconds from a timestamp that may be recorded in either unit. Crush's
|
|
114
|
+
* `sessions.created_at` is annotated "Unix timestamp in milliseconds" in its
|
|
115
|
+
* own schema but is written in *seconds* (verified against a live store), so
|
|
116
|
+
* neither the comment nor a single observation is worth trusting on its own.
|
|
117
|
+
* Values past ~2001 in ms are unambiguous, so magnitude decides. */
|
|
118
|
+
function epochSeconds(ts) {
|
|
119
|
+
return ts > 1e12 ? Math.floor(ts / 1000) : ts;
|
|
120
|
+
}
|
|
121
|
+
// Memoize a session's parsed per-message entries on its file (size, mtime)
|
|
122
|
+
// signature so the daemon's periodic cost sampler doesn't re-read an unchanged
|
|
123
|
+
// multi-MB transcript every tick. Invalidated automatically when the file grows.
|
|
124
|
+
const claudeUsageCache = new Map();
|
|
125
|
+
// Parse a session transcript into priced per-message entries, deduping streamed
|
|
126
|
+
// duplicates (same message id repeated within the file). Returns null when the
|
|
127
|
+
// session file is gone or unreadable.
|
|
128
|
+
function readClaudeUsageEntries(sessionId, foldPath) {
|
|
129
|
+
return readClaudeUsageEntriesSigned(sessionId, foldPath)?.entries ?? null;
|
|
130
|
+
}
|
|
131
|
+
function readClaudeUsageEntriesSigned(sessionId, foldPath) {
|
|
132
|
+
const cwdEncoded = foldPath.replace(/\//g, "-");
|
|
133
|
+
const sessionFile = join(homedir(), ".claude", "projects", cwdEncoded, `${sessionId}.jsonl`);
|
|
134
|
+
let sig;
|
|
135
|
+
try {
|
|
136
|
+
const st = statSync(sessionFile);
|
|
137
|
+
sig = `${st.size}:${st.mtimeMs}`;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null; // session file gone or never existed
|
|
141
|
+
}
|
|
142
|
+
const cached = claudeUsageCache.get(sessionFile);
|
|
143
|
+
if (cached && cached.sig === sig)
|
|
144
|
+
return cached;
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = readFileSync(sessionFile, "utf-8");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const entries = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
for (const line of raw.split("\n")) {
|
|
155
|
+
if (!line)
|
|
156
|
+
continue;
|
|
157
|
+
let obj;
|
|
158
|
+
try {
|
|
159
|
+
obj = JSON.parse(line);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const message = obj.message;
|
|
165
|
+
const usage = message?.usage;
|
|
166
|
+
if (!usage)
|
|
167
|
+
continue;
|
|
168
|
+
const id = typeof message?.id === "string" ? message.id : undefined;
|
|
169
|
+
if (id) {
|
|
170
|
+
if (seen.has(id))
|
|
171
|
+
continue; // dedupe streamed duplicates
|
|
172
|
+
seen.add(id);
|
|
173
|
+
}
|
|
174
|
+
const i = num(usage.input_tokens);
|
|
175
|
+
const o = num(usage.output_tokens);
|
|
176
|
+
const r = num(usage.cache_read_input_tokens);
|
|
177
|
+
const cc = usage.cache_creation;
|
|
178
|
+
const w5 = num(cc?.ephemeral_5m_input_tokens);
|
|
179
|
+
const w1 = num(cc?.ephemeral_1h_input_tokens);
|
|
180
|
+
const model = typeof message?.model === "string" ? message.model : undefined;
|
|
181
|
+
const speed = typeof usage.speed === "string" ? usage.speed : undefined;
|
|
182
|
+
entries.push({
|
|
183
|
+
id,
|
|
184
|
+
model,
|
|
185
|
+
cost: priceMessage(model ?? "", {
|
|
186
|
+
input: i,
|
|
187
|
+
output: o,
|
|
188
|
+
cacheRead: r,
|
|
189
|
+
cacheWrite5m: w5,
|
|
190
|
+
cacheWrite1h: w1,
|
|
191
|
+
speed,
|
|
192
|
+
}),
|
|
193
|
+
input: i,
|
|
194
|
+
output: o,
|
|
195
|
+
cacheRead: r,
|
|
196
|
+
cacheWrite: w5 + w1,
|
|
197
|
+
cacheWrite1h: w1,
|
|
198
|
+
speed,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const result = { sig, entries };
|
|
202
|
+
claudeUsageCache.set(sessionFile, result);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
/** Ledger key for a claude message. Id-less records (`<synthetic>` messages)
|
|
206
|
+
* carry no identity to dedup on, so they key on their position in the file
|
|
207
|
+
* they were read from: unique and stable for an append-only transcript. A
|
|
208
|
+
* fork that copies one gets a second key for it — accepted, because
|
|
209
|
+
* synthetic records carry no usage, so only the row count is affected. */
|
|
210
|
+
function claudeEntryKey(sessionId, e, index) {
|
|
211
|
+
return e.id ? `claude:${e.id}` : `claude:${sessionId}#${index}`;
|
|
212
|
+
}
|
|
213
|
+
// Sum priced entries from one or more session transcripts into a single
|
|
214
|
+
// reading. One seen-set spans all the sessions (walked in bind order), so each
|
|
215
|
+
// message id counts only at its first sighting: fork-copied history prices to
|
|
216
|
+
// zero in successor files, while a genuinely diverged fork's distinct new
|
|
217
|
+
// messages on both sides still count.
|
|
218
|
+
function sumClaudeEntries(sessions) {
|
|
219
|
+
let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
|
|
220
|
+
const seen = new Set();
|
|
221
|
+
for (const entries of sessions) {
|
|
222
|
+
for (const e of entries) {
|
|
223
|
+
if (e.id) {
|
|
224
|
+
if (seen.has(e.id))
|
|
225
|
+
continue;
|
|
226
|
+
seen.add(e.id);
|
|
227
|
+
}
|
|
228
|
+
input += e.input;
|
|
229
|
+
output += e.output;
|
|
230
|
+
cacheRead += e.cacheRead;
|
|
231
|
+
cacheWrite += e.cacheWrite;
|
|
232
|
+
cost += e.cost;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
amount: Math.round(cost * 1e4) / 1e4,
|
|
237
|
+
unit: "USD",
|
|
238
|
+
tokens: { input, output, cacheRead, cacheWrite },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Claude Code: stores sessions at ~/.claude/projects/<path-encoded>/
|
|
242
|
+
const claudeAdapter = {
|
|
243
|
+
// Claude accepts a caller-assigned id via `--session-id`, so the daemon mints
|
|
244
|
+
// and pins it (see buildAgentArgs / reviveNarrative). First agent through the
|
|
245
|
+
// "assign" gate, not a special-cased one.
|
|
246
|
+
sessionIdOwnership: "assign",
|
|
247
|
+
resumeCreatesNewSession: true,
|
|
248
|
+
liveSessionId: readClaudeLiveSession,
|
|
249
|
+
resumeArgs({ sessionId, foldPath, forkSession, newSessionId }) {
|
|
250
|
+
// A fresh start still pins the daemon-minted id when one is supplied, the
|
|
251
|
+
// same way spawn does — otherwise the row's stale pointer survives the
|
|
252
|
+
// revive and keeps naming a session that no longer exists.
|
|
253
|
+
const freshStart = newSessionId ? ["--session-id", newSessionId] : [];
|
|
254
|
+
// Per-narrative session ID is the only safe key. Falling back to
|
|
255
|
+
// discoverSessionId(foldPath) cross-contaminates narratives that share
|
|
256
|
+
// a fold: it picks the most-recently-modified jsonl in the fold's project
|
|
257
|
+
// dir, which can belong to an unrelated narrative. Start fresh instead.
|
|
258
|
+
if (!sessionId)
|
|
259
|
+
return freshStart;
|
|
260
|
+
// Validate the .jsonl file still exists
|
|
261
|
+
const cwdEncoded = foldPath.replace(/\//g, "-");
|
|
262
|
+
const sessionFile = join(homedir(), ".claude", "projects", cwdEncoded, `${sessionId}.jsonl`);
|
|
263
|
+
try {
|
|
264
|
+
statSync(sessionFile);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Session file gone (e.g., after cleanup), start fresh
|
|
268
|
+
return freshStart;
|
|
269
|
+
}
|
|
270
|
+
// Any `--resume` rotates to a fresh session id — Claude Code never
|
|
271
|
+
// continues under the old one — so an un-pinned resume strands the row's
|
|
272
|
+
// agent_session_id on the dead predecessor (frozen cost readings, and the
|
|
273
|
+
// next revive resumes the pre-revive fork point). With a daemon-minted id
|
|
274
|
+
// in hand, always resume fork-style and pin the successor's id via
|
|
275
|
+
// `--session-id`; `--fork-session` also covers the case where the original
|
|
276
|
+
// is still live (e.g. background-agent mode), which refuses a plain
|
|
277
|
+
// `--resume`.
|
|
278
|
+
if (newSessionId) {
|
|
279
|
+
return ["--resume", sessionId, "--fork-session", "--session-id", newSessionId];
|
|
280
|
+
}
|
|
281
|
+
if (forkSession)
|
|
282
|
+
return ["--resume", sessionId, "--fork-session"];
|
|
283
|
+
return ["--resume", sessionId];
|
|
284
|
+
},
|
|
285
|
+
discoverSessionId(foldPath) {
|
|
286
|
+
const cwdEncoded = foldPath.replace(/\//g, "-");
|
|
287
|
+
const projectDir = join(homedir(), ".claude", "projects", cwdEncoded);
|
|
288
|
+
try {
|
|
289
|
+
const files = readdirSync(projectDir);
|
|
290
|
+
let latestId;
|
|
291
|
+
let latestMtime = 0;
|
|
292
|
+
for (const file of files) {
|
|
293
|
+
// Skip non-jsonl and agent-* files
|
|
294
|
+
if (!file.endsWith(".jsonl") || file.startsWith("agent-"))
|
|
295
|
+
continue;
|
|
296
|
+
const id = file.replace(/\.jsonl$/, "");
|
|
297
|
+
// UUID-like: contains dashes
|
|
298
|
+
if (!id.includes("-"))
|
|
299
|
+
continue;
|
|
300
|
+
try {
|
|
301
|
+
const stat = statSync(join(projectDir, file));
|
|
302
|
+
const mtime = stat.mtimeMs;
|
|
303
|
+
if (mtime > latestMtime) {
|
|
304
|
+
latestMtime = mtime;
|
|
305
|
+
latestId = id;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return latestId;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
// Claude can switch models mid-session (/model), so model is per-message.
|
|
319
|
+
modelGranularity: "per-message",
|
|
320
|
+
// Parse the whole session JSONL once into a `say` → {offset, model} map.
|
|
321
|
+
// Each assistant turn carrying an mcp__brane__narrate tool-call contributes
|
|
322
|
+
// one entry keyed by its exact `say` text; the assistant message UUID is a
|
|
323
|
+
// stable, append-only-immutable offset and message.model is the truth.
|
|
324
|
+
// First occurrence wins, matching resolveTrace's original first-match scan.
|
|
325
|
+
buildTraceIndex({ sessionId, foldPath }) {
|
|
326
|
+
const cwdEncoded = foldPath.replace(/\//g, "-");
|
|
327
|
+
const sessionFile = join(homedir(), ".claude", "projects", cwdEncoded, `${sessionId}.jsonl`);
|
|
328
|
+
let raw;
|
|
329
|
+
try {
|
|
330
|
+
raw = readFileSync(sessionFile, "utf-8");
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return null; // session file gone or never existed
|
|
334
|
+
}
|
|
335
|
+
const index = new Map();
|
|
336
|
+
for (const line of raw.split("\n")) {
|
|
337
|
+
if (!line)
|
|
338
|
+
continue;
|
|
339
|
+
let obj;
|
|
340
|
+
try {
|
|
341
|
+
obj = JSON.parse(line);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (obj.type !== "assistant")
|
|
347
|
+
continue;
|
|
348
|
+
const message = obj.message;
|
|
349
|
+
const blocks = message?.content;
|
|
350
|
+
if (!Array.isArray(blocks))
|
|
351
|
+
continue;
|
|
352
|
+
for (const block of blocks) {
|
|
353
|
+
if (block &&
|
|
354
|
+
typeof block === "object" &&
|
|
355
|
+
block.type === "tool_use" &&
|
|
356
|
+
block.name === "mcp__brane__narrate") {
|
|
357
|
+
const input = block.input;
|
|
358
|
+
if (input && typeof input.say === "string" && !index.has(input.say)) {
|
|
359
|
+
index.set(input.say, {
|
|
360
|
+
offset: typeof obj.uuid === "string" ? obj.uuid : undefined,
|
|
361
|
+
model: typeof message?.model === "string" ? message.model : undefined,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return index;
|
|
368
|
+
},
|
|
369
|
+
// Thin lookup over buildTraceIndex — preserved for callers that resolve a
|
|
370
|
+
// single narration. Batch callers should use buildTraceIndex directly to
|
|
371
|
+
// parse each transcript once.
|
|
372
|
+
resolveTrace({ sessionId, foldPath, content }) {
|
|
373
|
+
const index = this.buildTraceIndex({ sessionId, foldPath });
|
|
374
|
+
return index?.get(content) ?? null;
|
|
375
|
+
},
|
|
376
|
+
// Cumulative USD cost from the session JSONL: sum each assistant message's
|
|
377
|
+
// token usage, priced by its *own* model (Claude can switch models
|
|
378
|
+
// mid-session via /model). Deduped by message id to skip streamed
|
|
379
|
+
// duplicates, matching the `brane-cost` reference. Memoized on the file
|
|
380
|
+
// signature so unchanged transcripts aren't re-parsed.
|
|
381
|
+
readUsage({ sessionId, foldPath }) {
|
|
382
|
+
const entries = readClaudeUsageEntries(sessionId, foldPath);
|
|
383
|
+
if (!entries)
|
|
384
|
+
return null;
|
|
385
|
+
return sumClaudeEntries([entries]);
|
|
386
|
+
},
|
|
387
|
+
// Narrative-level cost: sum every bound session's entries with one shared
|
|
388
|
+
// seen-set so fork-copied history (see readUsageMany in AgentAdapter) counts
|
|
389
|
+
// once. Null only when no session file is readable at all.
|
|
390
|
+
readUsageMany({ sessionIds, foldPath }) {
|
|
391
|
+
const sessions = sessionIds
|
|
392
|
+
.map((sessionId) => readClaudeUsageEntries(sessionId, foldPath))
|
|
393
|
+
.filter((entries) => entries !== null);
|
|
394
|
+
if (sessions.length === 0)
|
|
395
|
+
return null;
|
|
396
|
+
return sumClaudeEntries(sessions);
|
|
397
|
+
},
|
|
398
|
+
// Same entries, keyed for the ledger. No seen-set here: the ledger's primary
|
|
399
|
+
// key is the dedup, and it has to be, because it is the only copy that
|
|
400
|
+
// survives the harness purging a transcript.
|
|
401
|
+
readUsageEntries({ sessionIds, foldPath }) {
|
|
402
|
+
const readings = [];
|
|
403
|
+
for (const sessionId of sessionIds) {
|
|
404
|
+
const read = readClaudeUsageEntriesSigned(sessionId, foldPath);
|
|
405
|
+
if (!read)
|
|
406
|
+
continue;
|
|
407
|
+
readings.push({
|
|
408
|
+
sessionId,
|
|
409
|
+
sig: read.sig,
|
|
410
|
+
unit: "USD",
|
|
411
|
+
entries: read.entries.map((e, i) => ({
|
|
412
|
+
key: claudeEntryKey(sessionId, e, i),
|
|
413
|
+
amount: e.cost,
|
|
414
|
+
model: e.model,
|
|
415
|
+
tokens: {
|
|
416
|
+
input: e.input,
|
|
417
|
+
output: e.output,
|
|
418
|
+
cacheRead: e.cacheRead,
|
|
419
|
+
cacheWrite: e.cacheWrite,
|
|
420
|
+
cacheWrite1h: e.cacheWrite1h,
|
|
421
|
+
},
|
|
422
|
+
speed: e.speed,
|
|
423
|
+
})),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return readings.length ? readings : null;
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
// Goose keeps every session in one SQLite database (WAL), not per-session
|
|
430
|
+
// files: `sessions` is a row per session and `usage_ledger` a row per LLM
|
|
431
|
+
// request. Overridable for tests.
|
|
432
|
+
function gooseSessionsDb() {
|
|
433
|
+
return join(homedir(), ".local", "share", "goose", "sessions", "sessions.db");
|
|
434
|
+
}
|
|
435
|
+
function copilotSessionStoreDb() {
|
|
436
|
+
return join(homedir(), ".copilot", "session-store.db");
|
|
437
|
+
}
|
|
438
|
+
/** Copilot stores the metered amount in nano AIU, so divide exactly once at
|
|
439
|
+
* the harness boundary and expose the existing unit-agnostic cost surface as
|
|
440
|
+
* AIC. Token columns are per request and can be summed independently. */
|
|
441
|
+
export function readCopilotUsage(dbPath, sessionId) {
|
|
442
|
+
return memoized(`copilot:${dbPath}:${sessionId}`, [dbPath, `${dbPath}-wal`], () => withReadonlyDb(dbPath, (db) => {
|
|
443
|
+
const row = db
|
|
444
|
+
.prepare(`SELECT COUNT(*) AS count,
|
|
445
|
+
SUM(COALESCE(total_nano_aiu, 0)) AS nanoAiu,
|
|
446
|
+
SUM(COALESCE(input_tokens, 0)) AS input,
|
|
447
|
+
SUM(COALESCE(output_tokens, 0)) AS output,
|
|
448
|
+
SUM(COALESCE(cache_read_tokens, 0)) AS cacheRead,
|
|
449
|
+
SUM(COALESCE(cache_write_tokens, 0)) AS cacheWrite
|
|
450
|
+
FROM assistant_usage_events WHERE session_id = ?`)
|
|
451
|
+
.get(sessionId);
|
|
452
|
+
if (row.count === 0)
|
|
453
|
+
return null;
|
|
454
|
+
return {
|
|
455
|
+
amount: Math.round((row.nanoAiu / 1e9) * 1e9) / 1e9,
|
|
456
|
+
unit: "AIC",
|
|
457
|
+
tokens: { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite },
|
|
458
|
+
};
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
/** Resolve the goose session id backing a narrative. Exact, not heuristic:
|
|
462
|
+
* brane launches goose as `-n <narrativeId>` at both spawn and revive, and
|
|
463
|
+
* goose stores that verbatim as `sessions.name`, so the narrative id *is* the
|
|
464
|
+
* lookup key. That avoids the fold+time scoping codex needs (and its
|
|
465
|
+
* same-fold cross-assignment race) entirely.
|
|
466
|
+
*
|
|
467
|
+
* Because revive reuses `-n <nid>`, goose resumes the same named row rather
|
|
468
|
+
* than minting a new session — so unlike cline and crush, a goose narrative
|
|
469
|
+
* keeps one session id for life and needs no post-revive re-capture.
|
|
470
|
+
*
|
|
471
|
+
* Newest-first only to be safe: `name` is not declared UNIQUE, so a goose that
|
|
472
|
+
* somehow failed to resume could leave two rows sharing it, and the latest is
|
|
473
|
+
* then the live one. */
|
|
474
|
+
export function discoverGooseSession(dbPath, narrativeId) {
|
|
475
|
+
return (withReadonlyDb(dbPath, (db) => {
|
|
476
|
+
const row = db
|
|
477
|
+
.prepare("SELECT id FROM sessions WHERE name = ? ORDER BY created_at DESC LIMIT 1")
|
|
478
|
+
.get(narrativeId);
|
|
479
|
+
return typeof row?.id === "string" ? row.id : null;
|
|
480
|
+
}) ?? undefined);
|
|
481
|
+
}
|
|
482
|
+
/** Price a goose session from its usage ledger. Goose records usage faithfully
|
|
483
|
+
* but prices nothing — `usage_ledger.cost`/`cost_source` are NULL for a
|
|
484
|
+
* custom provider — so brane owns the pricing here, unlike cline and crush
|
|
485
|
+
* which report USD the harness already computed.
|
|
486
|
+
*
|
|
487
|
+
* Grouped by model because a session can switch models mid-flight; each group
|
|
488
|
+
* is priced by its own rate. `is_compaction` rows are real spend and count. */
|
|
489
|
+
export function readGooseUsage(dbPath, sessionId) {
|
|
490
|
+
return memoized(`goose:${dbPath}:${sessionId}`, [dbPath, `${dbPath}-wal`], () => withReadonlyDb(dbPath, (db) => {
|
|
491
|
+
const rows = db
|
|
492
|
+
.prepare(`SELECT model,
|
|
493
|
+
SUM(COALESCE(input_tokens, 0)) AS input,
|
|
494
|
+
SUM(COALESCE(output_tokens, 0)) AS output,
|
|
495
|
+
SUM(COALESCE(cache_read_tokens, 0)) AS cacheRead,
|
|
496
|
+
SUM(COALESCE(cache_write_tokens, 0)) AS cacheWrite
|
|
497
|
+
FROM usage_ledger
|
|
498
|
+
WHERE session_id = ?
|
|
499
|
+
GROUP BY model`)
|
|
500
|
+
.all(sessionId);
|
|
501
|
+
if (rows.length === 0)
|
|
502
|
+
return null;
|
|
503
|
+
let cost = 0, input = 0, output = 0, cacheRead = 0, cacheWrite = 0;
|
|
504
|
+
for (const r of rows) {
|
|
505
|
+
// Goose's `input_tokens` is *inclusive* of the cache-read portion
|
|
506
|
+
// (a live sample read 172,498 input of which 171,667 were cache
|
|
507
|
+
// reads), whereas priceMessage expects Anthropic-style disjoint
|
|
508
|
+
// counts. Subtract, and clamp: a provider that reported them
|
|
509
|
+
// disjointly would otherwise price as a negative.
|
|
510
|
+
const freshInput = Math.max(0, r.input - r.cacheRead);
|
|
511
|
+
// A NULL model falls through priceForModel to the most-expensive known
|
|
512
|
+
// tier, per the price table's never-under-report policy. Not observed
|
|
513
|
+
// in a live ledger, but worth knowing the error direction is over.
|
|
514
|
+
cost += priceMessage(r.model ?? "", {
|
|
515
|
+
input: freshInput,
|
|
516
|
+
output: r.output,
|
|
517
|
+
cacheRead: r.cacheRead,
|
|
518
|
+
// Goose records one undifferentiated cache-write count with no TTL,
|
|
519
|
+
// so bill it at the 5-minute rate — right for Anthropic-backed
|
|
520
|
+
// sessions, and moot for Fireworks, which never charges for cache
|
|
521
|
+
// writes and always reports zero here.
|
|
522
|
+
cacheWrite5m: r.cacheWrite,
|
|
523
|
+
cacheWrite1h: 0,
|
|
524
|
+
});
|
|
525
|
+
input += r.input;
|
|
526
|
+
output += r.output;
|
|
527
|
+
cacheRead += r.cacheRead;
|
|
528
|
+
cacheWrite += r.cacheWrite;
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
amount: Math.round(cost * 1e4) / 1e4,
|
|
532
|
+
unit: "USD",
|
|
533
|
+
// Report goose's own inclusive input count, matching what its ledger
|
|
534
|
+
// says; only the pricing math needs the disjoint split.
|
|
535
|
+
tokens: { input, output, cacheRead, cacheWrite },
|
|
536
|
+
};
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
// Goose: resumes by session name (narrative ID) with -r flag
|
|
540
|
+
const gooseAdapter = {
|
|
541
|
+
// Goose mints its own session id (`<yyyymmdd>_<n>`); brane learns it by
|
|
542
|
+
// looking up the name it launched goose under. Capture rather than assign.
|
|
543
|
+
sessionIdOwnership: "capture",
|
|
544
|
+
resumeArgs() {
|
|
545
|
+
return ["-r"];
|
|
546
|
+
},
|
|
547
|
+
// foldPath/since are unused: the narrative id is an exact key, which is
|
|
548
|
+
// strictly better than scoping by fold and spawn time.
|
|
549
|
+
discoverSessionId(_foldPath, _since, narrativeId) {
|
|
550
|
+
if (!narrativeId)
|
|
551
|
+
return undefined;
|
|
552
|
+
return discoverGooseSession(gooseSessionsDb(), narrativeId);
|
|
553
|
+
},
|
|
554
|
+
readUsage({ sessionId }) {
|
|
555
|
+
return readGooseUsage(gooseSessionsDb(), sessionId);
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
// Gemini: no session resume support (uses -i for prompt injection)
|
|
559
|
+
const geminiAdapter = {
|
|
560
|
+
resumeArgs() {
|
|
561
|
+
return [];
|
|
562
|
+
},
|
|
563
|
+
discoverSessionId() {
|
|
564
|
+
return undefined;
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
// Read just the first line of a file without slurping a multi-MB rollout: read a
|
|
568
|
+
// bounded prefix and cut at the first newline. Codex's `session_meta` line embeds
|
|
569
|
+
// the full base instructions, so it can be several KB — 256KB is ample headroom.
|
|
570
|
+
// Degradation (accepted): if a first line somehow exceeds `maxBytes` there's no
|
|
571
|
+
// newline in the prefix, so the truncated slice fails to JSON-parse and that
|
|
572
|
+
// rollout is skipped (its narrative just won't auto-resume). Only reachable with a
|
|
573
|
+
// pathologically large base_instructions block; a larger cap trades memory for it.
|
|
574
|
+
function readFirstLine(path, maxBytes = 1 << 18) {
|
|
575
|
+
let fd;
|
|
576
|
+
try {
|
|
577
|
+
fd = openSync(path, "r");
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const buf = Buffer.allocUnsafe(maxBytes);
|
|
584
|
+
const n = readSync(fd, buf, 0, maxBytes, 0);
|
|
585
|
+
const s = buf.toString("utf8", 0, n);
|
|
586
|
+
const nl = s.indexOf("\n");
|
|
587
|
+
return nl === -1 ? s : s.slice(0, nl);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
closeSync(fd);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Parse a codex rollout's leading `session_meta` event into the fields we scope
|
|
597
|
+
// on. Returns undefined for anything that isn't a well-formed session_meta line.
|
|
598
|
+
function readCodexSessionMeta(path) {
|
|
599
|
+
const line = readFirstLine(path);
|
|
600
|
+
if (!line)
|
|
601
|
+
return undefined;
|
|
602
|
+
let obj;
|
|
603
|
+
try {
|
|
604
|
+
obj = JSON.parse(line);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
if (!obj || obj.type !== "session_meta")
|
|
610
|
+
return undefined;
|
|
611
|
+
const p = obj.payload;
|
|
612
|
+
if (!p ||
|
|
613
|
+
typeof p.id !== "string" ||
|
|
614
|
+
typeof p.cwd !== "string" ||
|
|
615
|
+
typeof p.timestamp !== "string") {
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
const ts = Date.parse(p.timestamp);
|
|
619
|
+
if (!Number.isFinite(ts))
|
|
620
|
+
return undefined;
|
|
621
|
+
return { id: p.id, cwd: p.cwd, ts };
|
|
622
|
+
}
|
|
623
|
+
// Return numeric-named children of a directory as { name, n } (e.g. the
|
|
624
|
+
// "2026"/"03"/"05" tiers of codex's sessions tree), skipping non-numeric names.
|
|
625
|
+
function numericEntries(dir) {
|
|
626
|
+
let names;
|
|
627
|
+
try {
|
|
628
|
+
names = readdirSync(dir);
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
return names
|
|
634
|
+
.map((name) => ({ name, n: Number(name) }))
|
|
635
|
+
.filter((e) => Number.isInteger(e.n));
|
|
636
|
+
}
|
|
637
|
+
// Discover a codex session id for `foldPath` from its rollout store, laid out as
|
|
638
|
+
// `<sessionsDir>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`. Each rollout's first line
|
|
639
|
+
// is a `session_meta` event carrying { id, cwd, timestamp }. We pick *this*
|
|
640
|
+
// narrative's own rollout: cwd must equal the fold, and — given a spawn time
|
|
641
|
+
// `since` — the session must have started after it, taking the EARLIEST such
|
|
642
|
+
// start (the rollout created right after this spawn). The cwd + since scoping is
|
|
643
|
+
// what stops two codex narratives sharing a fold from cross-contaminating — the
|
|
644
|
+
// fold-wide-latest trap the claude adapter documents. Exported for testing
|
|
645
|
+
// against a fixture tree; production passes `~/.codex/sessions`.
|
|
646
|
+
//
|
|
647
|
+
// Known limitation (accepted, filesystem-discovery inherent): "earliest after
|
|
648
|
+
// `since`" assumes rollout-creation order matches spawn order. Two codex
|
|
649
|
+
// narratives spawned into the *same fold* near-simultaneously, where the
|
|
650
|
+
// later-spawned one writes its `session_meta` first, could cross-assign. It's a
|
|
651
|
+
// narrow race with no tighter handle available (codex owns its own id and we
|
|
652
|
+
// only learn it from disk); the single-narrative and staggered-spawn cases — the
|
|
653
|
+
// norm — are correct. Cf. the resume-rotation limitation on maybeCaptureSessionId.
|
|
654
|
+
export function discoverCodexSession(sessionsDir, foldPath, since) {
|
|
655
|
+
const sinceDate = since !== undefined ? new Date(since) : undefined;
|
|
656
|
+
const wantCwd = resolve(foldPath);
|
|
657
|
+
let best;
|
|
658
|
+
for (const y of numericEntries(sessionsDir)) {
|
|
659
|
+
if (sinceDate && y.n < sinceDate.getFullYear())
|
|
660
|
+
continue;
|
|
661
|
+
const yDir = join(sessionsDir, y.name);
|
|
662
|
+
for (const mo of numericEntries(yDir)) {
|
|
663
|
+
if (sinceDate && y.n === sinceDate.getFullYear() && mo.n < sinceDate.getMonth() + 1)
|
|
664
|
+
continue;
|
|
665
|
+
const moDir = join(yDir, mo.name);
|
|
666
|
+
for (const d of numericEntries(moDir)) {
|
|
667
|
+
if (sinceDate &&
|
|
668
|
+
y.n === sinceDate.getFullYear() &&
|
|
669
|
+
mo.n === sinceDate.getMonth() + 1 &&
|
|
670
|
+
d.n < sinceDate.getDate()) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const dDir = join(moDir, d.name);
|
|
674
|
+
let files;
|
|
675
|
+
try {
|
|
676
|
+
files = readdirSync(dDir);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
for (const f of files) {
|
|
682
|
+
if (!f.startsWith("rollout-") || !f.endsWith(".jsonl"))
|
|
683
|
+
continue;
|
|
684
|
+
const meta = readCodexSessionMeta(join(dDir, f));
|
|
685
|
+
if (!meta)
|
|
686
|
+
continue;
|
|
687
|
+
if (resolve(meta.cwd) !== wantCwd)
|
|
688
|
+
continue;
|
|
689
|
+
if (since !== undefined && meta.ts <= since)
|
|
690
|
+
continue;
|
|
691
|
+
if (!best || meta.ts < best.ts)
|
|
692
|
+
best = { id: meta.id, ts: meta.ts };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return best?.id;
|
|
698
|
+
}
|
|
699
|
+
/** Default root of codex's rollout store. */
|
|
700
|
+
export function codexSessionsRoot() {
|
|
701
|
+
return join(homedir(), ".codex", "sessions");
|
|
702
|
+
}
|
|
703
|
+
/** sessionId → rollout path. A rollout is named for the session it records and
|
|
704
|
+
* is never renamed, so the mapping is stable for the life of the process and
|
|
705
|
+
* worth caching: it is the only part of a usage or tool-log read that must
|
|
706
|
+
* walk the tree. Misses are not cached — a young session has no file yet. */
|
|
707
|
+
const rolloutPaths = new Map();
|
|
708
|
+
/** Find the rollout recording `sessionId`, or undefined if there isn't one yet.
|
|
709
|
+
*
|
|
710
|
+
* The filename ends with `-<sessionId>.jsonl`, which makes the scan a string
|
|
711
|
+
* test per entry, but the name is only a hint: `session_meta` is the
|
|
712
|
+
* authority, so a match is confirmed by reading the first line. Exported for
|
|
713
|
+
* the tool-log reader and for testing against a fixture tree. */
|
|
714
|
+
export function findCodexRollout(sessionId, sessionsRoot = codexSessionsRoot()) {
|
|
715
|
+
const key = `${sessionsRoot} ${sessionId}`;
|
|
716
|
+
const cached = rolloutPaths.get(key);
|
|
717
|
+
if (cached !== undefined) {
|
|
718
|
+
try {
|
|
719
|
+
statSync(cached);
|
|
720
|
+
return cached;
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
rolloutPaths.delete(key); // moved out from under us; fall through to rescan
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
727
|
+
for (const y of numericEntries(sessionsRoot)) {
|
|
728
|
+
const yDir = join(sessionsRoot, y.name);
|
|
729
|
+
for (const mo of numericEntries(yDir)) {
|
|
730
|
+
const moDir = join(yDir, mo.name);
|
|
731
|
+
for (const d of numericEntries(moDir)) {
|
|
732
|
+
const dDir = join(moDir, d.name);
|
|
733
|
+
let files;
|
|
734
|
+
try {
|
|
735
|
+
files = readdirSync(dDir);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
for (const f of files) {
|
|
741
|
+
if (!f.endsWith(suffix))
|
|
742
|
+
continue;
|
|
743
|
+
const path = join(dDir, f);
|
|
744
|
+
if (readCodexSessionMeta(path)?.id !== sessionId)
|
|
745
|
+
continue;
|
|
746
|
+
rolloutPaths.set(key, path);
|
|
747
|
+
return path;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return undefined;
|
|
753
|
+
}
|
|
754
|
+
/** Memoized on the rollout's (size, mtime), so a cost-sampler tick against an
|
|
755
|
+
* unchanged file costs one `stat` and nothing else. */
|
|
756
|
+
const codexUsageCache = new Map();
|
|
757
|
+
/** Bytes of the tail searched for the last `token_count`, doubling up to the
|
|
758
|
+
* whole file if the window lands in a stretch without one. Codex emits a
|
|
759
|
+
* `token_count` per turn, so the first window almost always holds several; the
|
|
760
|
+
* growth path covers a turn that appended a very large tool result after the
|
|
761
|
+
* counter. */
|
|
762
|
+
const CODEX_TAIL_WINDOW_BYTES = 1 << 16;
|
|
763
|
+
/** Read the tail of a file as lines, dropping the leading partial line unless
|
|
764
|
+
* the window starts at byte 0. Slicing the *buffer* and decoding after is
|
|
765
|
+
* deliberate: a window boundary landing inside a multi-byte sequence would
|
|
766
|
+
* otherwise leave a replacement character in the JSON. Returns [] when the
|
|
767
|
+
* window holds no line boundary at all, which tells the caller to widen. */
|
|
768
|
+
function readTailLines(path, size, windowBytes) {
|
|
769
|
+
const start = Math.max(0, size - windowBytes);
|
|
770
|
+
const length = size - start;
|
|
771
|
+
if (length <= 0)
|
|
772
|
+
return [];
|
|
773
|
+
let fd;
|
|
774
|
+
try {
|
|
775
|
+
fd = openSync(path, "r");
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
let buf;
|
|
781
|
+
try {
|
|
782
|
+
buf = Buffer.allocUnsafe(length);
|
|
783
|
+
const n = readSync(fd, buf, 0, length, start);
|
|
784
|
+
buf = buf.subarray(0, n);
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return [];
|
|
788
|
+
}
|
|
789
|
+
finally {
|
|
790
|
+
closeSync(fd);
|
|
791
|
+
}
|
|
792
|
+
if (start > 0) {
|
|
793
|
+
const nl = buf.indexOf(0x0a);
|
|
794
|
+
if (nl === -1)
|
|
795
|
+
return [];
|
|
796
|
+
buf = buf.subarray(nl + 1);
|
|
797
|
+
}
|
|
798
|
+
return buf.toString("utf8").split("\n");
|
|
799
|
+
}
|
|
800
|
+
/** Cumulative token usage for a codex session.
|
|
801
|
+
*
|
|
802
|
+
* Reported in `TOK`, not `USD`, and that is not a placeholder. Codex records
|
|
803
|
+
* exact counters but no durable model-price attribution, and `priceMessage`
|
|
804
|
+
* knows Anthropic models only — an unmatched id falls through to a
|
|
805
|
+
* $10/$50-per-Mtok default tier. Pricing a GPT session through that would not
|
|
806
|
+
* approximate anything; it would invoice an OpenAI transcript at Opus rates.
|
|
807
|
+
* `formatCost` already renders non-USD units, so the card stays truthful.
|
|
808
|
+
*
|
|
809
|
+
* No `readUsageMany` counterpart, deliberately: `total_token_usage` is
|
|
810
|
+
* cumulative *within one rollout* and starts at zero in a resumed session,
|
|
811
|
+
* which pays for its replayed context again as cache-read input. Summing
|
|
812
|
+
* per-session readings is therefore already correct — unlike claude, whose
|
|
813
|
+
* `--fork-session` copies priced history forward and must dedupe by id.
|
|
814
|
+
*
|
|
815
|
+
* Only the last counter matters, so this searches a tail window rather than
|
|
816
|
+
* the file: O(window), not O(session). A rollout passes two megabytes within
|
|
817
|
+
* the hour and this runs on the daemon's only thread. */
|
|
818
|
+
export function readCodexUsage(sessionId, sessionsRoot = codexSessionsRoot()) {
|
|
819
|
+
const path = findCodexRollout(sessionId, sessionsRoot);
|
|
820
|
+
if (!path)
|
|
821
|
+
return null;
|
|
822
|
+
let size, sig;
|
|
823
|
+
try {
|
|
824
|
+
const st = statSync(path);
|
|
825
|
+
size = st.size;
|
|
826
|
+
sig = `${st.size}:${st.mtimeMs}`;
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
const cached = codexUsageCache.get(path);
|
|
832
|
+
if (cached && cached.sig === sig)
|
|
833
|
+
return cached.reading;
|
|
834
|
+
let totals;
|
|
835
|
+
for (let window = CODEX_TAIL_WINDOW_BYTES;; window *= 2) {
|
|
836
|
+
for (const line of readTailLines(path, size, window)) {
|
|
837
|
+
// Cheap reject before parse: nearly every line is a message, reasoning,
|
|
838
|
+
// or tool traffic, and parsing them all is the cost this reader exists
|
|
839
|
+
// to avoid.
|
|
840
|
+
if (!line.includes('"token_count"'))
|
|
841
|
+
continue;
|
|
842
|
+
try {
|
|
843
|
+
const obj = JSON.parse(line);
|
|
844
|
+
if (obj.type !== "event_msg" || obj.payload?.type !== "token_count")
|
|
845
|
+
continue;
|
|
846
|
+
const t = obj.payload.info?.total_token_usage;
|
|
847
|
+
if (t)
|
|
848
|
+
totals = t;
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
// A line clipped by the window edge, or a partial final write.
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (totals || window >= size)
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
const finish = (reading) => {
|
|
858
|
+
codexUsageCache.set(path, { sig, reading });
|
|
859
|
+
return reading;
|
|
860
|
+
};
|
|
861
|
+
if (!totals)
|
|
862
|
+
return finish(null);
|
|
863
|
+
// Codex's `cached_input_tokens` is a *subset* of `input_tokens`, where claude
|
|
864
|
+
// reports the two disjointly. Subtract so a breakdown means the same thing
|
|
865
|
+
// whichever agent produced it, and so a consumer summing the fields doesn't
|
|
866
|
+
// count the cached portion twice. Clamped because a negative input would be a
|
|
867
|
+
// worse answer than a zero one.
|
|
868
|
+
const rawInput = num(totals.input_tokens);
|
|
869
|
+
const cacheRead = Math.min(num(totals.cached_input_tokens), rawInput);
|
|
870
|
+
const output = num(totals.output_tokens);
|
|
871
|
+
const total = num(totals.total_tokens) || rawInput + output;
|
|
872
|
+
if (!total)
|
|
873
|
+
return finish(null);
|
|
874
|
+
return finish({
|
|
875
|
+
amount: total,
|
|
876
|
+
unit: "TOK",
|
|
877
|
+
tokens: {
|
|
878
|
+
input: rawInput - cacheRead,
|
|
879
|
+
output,
|
|
880
|
+
cacheRead,
|
|
881
|
+
cacheWrite: num(totals.cache_write_input_tokens),
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/** Drop cached codex rollout state. Tests only — production state is
|
|
886
|
+
* process-lifetime and self-invalidating on the file signature. */
|
|
887
|
+
export function resetCodexCaches() {
|
|
888
|
+
rolloutPaths.clear();
|
|
889
|
+
codexUsageCache.clear();
|
|
890
|
+
}
|
|
891
|
+
// Codex: resumes via `codex resume <session_id>`. Codex generates its own
|
|
892
|
+
// session id (no caller-assign flag like claude's `--session-id`), so the daemon
|
|
893
|
+
// *captures* it from codex's rollout store rather than assigning it.
|
|
894
|
+
const codexAdapter = {
|
|
895
|
+
sessionIdOwnership: "capture",
|
|
896
|
+
resumeArgs({ sessionId }) {
|
|
897
|
+
if (!sessionId)
|
|
898
|
+
return [];
|
|
899
|
+
return ["resume", sessionId];
|
|
900
|
+
},
|
|
901
|
+
discoverSessionId(foldPath, since) {
|
|
902
|
+
return discoverCodexSession(codexSessionsRoot(), foldPath, since);
|
|
903
|
+
},
|
|
904
|
+
readUsage({ sessionId }) {
|
|
905
|
+
return readCodexUsage(sessionId);
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
// Amp: no simple CLI resume — uses thread references (@T-<id>) in prompt
|
|
909
|
+
const ampAdapter = {
|
|
910
|
+
resumeArgs() {
|
|
911
|
+
return [];
|
|
912
|
+
},
|
|
913
|
+
discoverSessionId() {
|
|
914
|
+
return undefined;
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
// Copilot accepts a caller-assigned UUID with --session-id, avoiding the
|
|
918
|
+
// fold-wide `--continue` ambiguity when multiple narratives share a fold.
|
|
919
|
+
const copilotAdapter = {
|
|
920
|
+
sessionIdOwnership: "assign",
|
|
921
|
+
resumeArgs({ sessionId, newSessionId }) {
|
|
922
|
+
const id = sessionId ?? newSessionId;
|
|
923
|
+
return id ? ["--session-id", id] : [];
|
|
924
|
+
},
|
|
925
|
+
discoverSessionId() {
|
|
926
|
+
return undefined;
|
|
927
|
+
},
|
|
928
|
+
readUsage({ sessionId }) {
|
|
929
|
+
return readCopilotUsage(copilotSessionStoreDb(), sessionId);
|
|
930
|
+
},
|
|
931
|
+
};
|
|
932
|
+
// Cline is launched with `--config /tmp/brane-cline-<nid>` (see
|
|
933
|
+
// ensureClineMcpConfig in commands/spawn.ts), so its whole store is private to
|
|
934
|
+
// one narrative — sessions live at
|
|
935
|
+
// `<dir>/data/sessions/<sid>/<sid>.messages.json`.
|
|
936
|
+
//
|
|
937
|
+
// Caveat, accepted: /tmp is cleared on reboot, so a narrative's cost history
|
|
938
|
+
// dies with it and the reading degrades to null. That's already the contract.
|
|
939
|
+
function clineSessionsDir(narrativeId) {
|
|
940
|
+
return join("/tmp", `brane-cline-${narrativeId}`, "data", "sessions");
|
|
941
|
+
}
|
|
942
|
+
function clineMessagesFile(narrativeId, sessionId) {
|
|
943
|
+
return join(clineSessionsDir(narrativeId), sessionId, `${sessionId}.messages.json`);
|
|
944
|
+
}
|
|
945
|
+
/** Sum one cline session's cost from its message log.
|
|
946
|
+
*
|
|
947
|
+
* Per-message `metrics.cost` rather than the session record's
|
|
948
|
+
* `metadata.totalCost`: cline accumulates that total only on the turn-success
|
|
949
|
+
* path, so a session with errored turns (an MCP listen timeout, say) persists
|
|
950
|
+
* the message metrics but silently drops their cost from the total. Summing
|
|
951
|
+
* the messages is never lower and sometimes much higher.
|
|
952
|
+
*
|
|
953
|
+
* The costs are cline's own, computed from its models.dev rate card, and are
|
|
954
|
+
* taken as authoritative — never re-priced. A narrative's provider can change
|
|
955
|
+
* across revives, so the rate that applied is knowable only to the harness
|
|
956
|
+
* that made the call. */
|
|
957
|
+
export function readClineSessionUsage(file) {
|
|
958
|
+
return memoized(`cline:${file}`, [file], () => {
|
|
959
|
+
let parsed;
|
|
960
|
+
try {
|
|
961
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
const messages = parsed?.messages;
|
|
967
|
+
if (!Array.isArray(messages))
|
|
968
|
+
return null;
|
|
969
|
+
let cost = 0, input = 0, output = 0, cacheRead = 0, cacheWrite = 0;
|
|
970
|
+
// Dedup within the file only. Cline never copies history between sessions
|
|
971
|
+
// (each revive starts an empty log), so the cross-file seen-set that
|
|
972
|
+
// claude's fork handling needs would here only risk *under*-counting two
|
|
973
|
+
// genuinely distinct messages that happened to share an id.
|
|
974
|
+
const seen = new Set();
|
|
975
|
+
for (const m of messages) {
|
|
976
|
+
const metrics = m?.metrics;
|
|
977
|
+
if (!metrics)
|
|
978
|
+
continue;
|
|
979
|
+
const id = m.id;
|
|
980
|
+
if (typeof id === "string") {
|
|
981
|
+
if (seen.has(id))
|
|
982
|
+
continue;
|
|
983
|
+
seen.add(id);
|
|
984
|
+
}
|
|
985
|
+
cost += num(metrics.cost);
|
|
986
|
+
input += num(metrics.inputTokens);
|
|
987
|
+
output += num(metrics.outputTokens);
|
|
988
|
+
cacheRead += num(metrics.cacheReadTokens);
|
|
989
|
+
cacheWrite += num(metrics.cacheWriteTokens);
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
amount: Math.round(cost * 1e6) / 1e6,
|
|
993
|
+
unit: "USD",
|
|
994
|
+
tokens: { input, output, cacheRead, cacheWrite },
|
|
995
|
+
};
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
/** Every session id present in a narrative's private cline store. */
|
|
999
|
+
function clineStoredSessions(narrativeId) {
|
|
1000
|
+
try {
|
|
1001
|
+
return readdirSync(clineSessionsDir(narrativeId), { withFileTypes: true })
|
|
1002
|
+
.filter((e) => e.isDirectory())
|
|
1003
|
+
.map((e) => e.name);
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
return [];
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
// Cline CLI: resumes via `--taskId <id>` (but we don't discover task IDs)
|
|
1010
|
+
const clineAdapter = {
|
|
1011
|
+
// Cline mints `<epochMs>_<rand5>` ids and starts a fresh session on every
|
|
1012
|
+
// revive, so brane captures them rather than assigning.
|
|
1013
|
+
sessionIdOwnership: "capture",
|
|
1014
|
+
resumeArgs() {
|
|
1015
|
+
return [];
|
|
1016
|
+
},
|
|
1017
|
+
// The config dir is per-narrative, so no fold scoping is needed — but `since`
|
|
1018
|
+
// still is, and cheaply: a cline session id is `<epochMs>_<rand5>`, so it
|
|
1019
|
+
// carries its own start time. Honouring it matters after a revive, when the
|
|
1020
|
+
// previous session is still the newest thing on disk for the moment it takes
|
|
1021
|
+
// the new one to appear; returning that incumbent would otherwise be taken
|
|
1022
|
+
// for the successor. Ids also sort lexicographically by start time, which
|
|
1023
|
+
// holds until epoch ms gains a digit in the year 33658.
|
|
1024
|
+
discoverSessionId(_foldPath, since, narrativeId) {
|
|
1025
|
+
if (!narrativeId)
|
|
1026
|
+
return undefined;
|
|
1027
|
+
return clineStoredSessions(narrativeId)
|
|
1028
|
+
.filter((id) => since === undefined || Number(id.split("_")[0]) >= since)
|
|
1029
|
+
.sort()
|
|
1030
|
+
.pop();
|
|
1031
|
+
},
|
|
1032
|
+
readUsage({ sessionId, narrativeId }) {
|
|
1033
|
+
if (!narrativeId)
|
|
1034
|
+
return null;
|
|
1035
|
+
return readClineSessionUsage(clineMessagesFile(narrativeId, sessionId));
|
|
1036
|
+
},
|
|
1037
|
+
// Sum every session in the narrative's private store, not just the ones
|
|
1038
|
+
// brane managed to record. The directory is brane-owned and strictly
|
|
1039
|
+
// per-narrative, so whatever is in it *is* this narrative's spend — which
|
|
1040
|
+
// keeps the badge correct for revives that happened before the daemon
|
|
1041
|
+
// learned to re-capture, and for any session captured late or not at all.
|
|
1042
|
+
// The recorded ids are still unioned in, so a session that has been moved
|
|
1043
|
+
// or pre-dates the current dir layout is never dropped.
|
|
1044
|
+
readUsageMany({ sessionIds, narrativeId }) {
|
|
1045
|
+
if (!narrativeId)
|
|
1046
|
+
return null;
|
|
1047
|
+
const ids = [...new Set([...clineStoredSessions(narrativeId), ...sessionIds])];
|
|
1048
|
+
const readings = ids.map((id) => readClineSessionUsage(clineMessagesFile(narrativeId, id)));
|
|
1049
|
+
return sumCostReadings(readings);
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
// Crush keeps one SQLite database per *fold* (`<fold>/.crush/crush.db`, WAL),
|
|
1053
|
+
// so unlike goose and cline its store is shared by every crush narrative in
|
|
1054
|
+
// that fold — hence the fold + spawn-time scoping below. The data dir is
|
|
1055
|
+
// configurable via crush's `options.data_directory`; only the default is
|
|
1056
|
+
// handled, and a fold that overrides it simply reports no cost.
|
|
1057
|
+
function crushDbPath(foldPath) {
|
|
1058
|
+
return join(foldPath, ".crush", "crush.db");
|
|
1059
|
+
}
|
|
1060
|
+
/** Crush spawns synthetic sessions alongside real ones: `title-<parentId>` for
|
|
1061
|
+
* title generation and `<messageID>$$<toolCallID>` for its `agent` tool. Both
|
|
1062
|
+
* are spend, but neither is the narrative's own session, so discovery must
|
|
1063
|
+
* skip them. Real sessions have a NULL parent. */
|
|
1064
|
+
const CRUSH_REAL_SESSION = "parent_session_id IS NULL AND id NOT LIKE 'title-%' AND id NOT LIKE '%$$%'";
|
|
1065
|
+
/** Discover the crush session a narrative spawned, by fold + start time.
|
|
1066
|
+
*
|
|
1067
|
+
* Earliest session starting at or after `since`, matching the codex adapter's
|
|
1068
|
+
* reasoning: the row created right after this spawn is ours, whereas
|
|
1069
|
+
* newest-in-fold would drift to an unrelated later session. It inherits
|
|
1070
|
+
* codex's known limitation too — two crush narratives spawned into one fold
|
|
1071
|
+
* within the same moment could cross-assign — because crush owns its ids and
|
|
1072
|
+
* we only learn them from disk.
|
|
1073
|
+
*
|
|
1074
|
+
* The unit handling is the subtle part: crush's schema annotates `created_at`
|
|
1075
|
+
* as milliseconds but writes seconds, so comparing a ms `since` against it
|
|
1076
|
+
* directly matches nothing, forever, with no error to notice. Both sides are
|
|
1077
|
+
* normalized to seconds; the truncation of `since` is deliberately the lenient
|
|
1078
|
+
* direction, so a session created in the same second as the spawn still
|
|
1079
|
+
* matches. */
|
|
1080
|
+
export function discoverCrushSession(dbPath, since) {
|
|
1081
|
+
return (withReadonlyDb(dbPath, (db) => {
|
|
1082
|
+
const rows = db
|
|
1083
|
+
.prepare(`SELECT id, created_at FROM sessions WHERE ${CRUSH_REAL_SESSION} ORDER BY created_at ASC`)
|
|
1084
|
+
.all();
|
|
1085
|
+
const floor = since === undefined ? undefined : Math.floor(since / 1000);
|
|
1086
|
+
for (const r of rows) {
|
|
1087
|
+
if (floor !== undefined && epochSeconds(r.created_at) < floor)
|
|
1088
|
+
continue;
|
|
1089
|
+
return r.id;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}) ?? undefined);
|
|
1093
|
+
}
|
|
1094
|
+
/** Read a crush session's cumulative USD cost. Crush computes and accumulates
|
|
1095
|
+
* cost itself (`session.Cost += cost` per request), so brane sums the stored
|
|
1096
|
+
* figure and never re-prices — the model and provider behind a session can
|
|
1097
|
+
* change, and the harness's own number is authoritative.
|
|
1098
|
+
*
|
|
1099
|
+
* Includes the session's direct children: crush's `agent` tool and its
|
|
1100
|
+
* title-generation pass each run in their own session row, and that spend is
|
|
1101
|
+
* the narrative's. (Only one level down — deeper nesting isn't something the
|
|
1102
|
+
* live store exercises, so it isn't something this claims to handle.)
|
|
1103
|
+
*
|
|
1104
|
+
* Deliberately returns no `tokens`: `prompt_tokens`/`completion_tokens` are
|
|
1105
|
+
* overwritten with each request's usage rather than accumulated — they exist
|
|
1106
|
+
* to gauge context size for auto-summarization — so reporting them as a
|
|
1107
|
+
* session total would be a lie. Crush would need a usage ledger for that.
|
|
1108
|
+
*
|
|
1109
|
+
* A $0 reading is meaningful and returned as-is: it means the fold's crush
|
|
1110
|
+
* config priced the model at zero (crush's default for a custom provider that
|
|
1111
|
+
* declares no `cost_per_1m_*` fields), not that usage is unknown. */
|
|
1112
|
+
export function readCrushUsage(dbPath, sessionId) {
|
|
1113
|
+
return memoized(`crush:${dbPath}:${sessionId}`, [dbPath, `${dbPath}-wal`], () => withReadonlyDb(dbPath, (db) => {
|
|
1114
|
+
const row = db
|
|
1115
|
+
.prepare(`SELECT SUM(cost) AS cost FROM sessions
|
|
1116
|
+
WHERE id = ? OR parent_session_id = ? OR id = 'title-' || ?`)
|
|
1117
|
+
.get(sessionId, sessionId, sessionId);
|
|
1118
|
+
if (row?.cost == null)
|
|
1119
|
+
return null; // no such session
|
|
1120
|
+
return { amount: Math.round(row.cost * 1e4) / 1e4, unit: "USD" };
|
|
1121
|
+
}));
|
|
1122
|
+
}
|
|
1123
|
+
// Crush (formerly OpenCode): no CLI session resume yet
|
|
1124
|
+
const crushAdapter = {
|
|
1125
|
+
// Crush mints its own uuid per session and has no resume, so every revive
|
|
1126
|
+
// starts a fresh row; brane captures each one for cost aggregation.
|
|
1127
|
+
sessionIdOwnership: "capture",
|
|
1128
|
+
resumeArgs() {
|
|
1129
|
+
return [];
|
|
1130
|
+
},
|
|
1131
|
+
discoverSessionId(foldPath, since) {
|
|
1132
|
+
return discoverCrushSession(crushDbPath(foldPath), since);
|
|
1133
|
+
},
|
|
1134
|
+
readUsage({ sessionId, foldPath }) {
|
|
1135
|
+
return readCrushUsage(crushDbPath(foldPath), sessionId);
|
|
1136
|
+
},
|
|
1137
|
+
};
|
|
1138
|
+
// Default adapter for agents without session resumption
|
|
1139
|
+
const defaultAdapter = {
|
|
1140
|
+
resumeArgs() {
|
|
1141
|
+
return [];
|
|
1142
|
+
},
|
|
1143
|
+
discoverSessionId() {
|
|
1144
|
+
return undefined;
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
// Registry of agent adapters. Add new agents here.
|
|
1148
|
+
const AGENTS = {
|
|
1149
|
+
claude: claudeAdapter,
|
|
1150
|
+
goose: gooseAdapter,
|
|
1151
|
+
gemini: geminiAdapter,
|
|
1152
|
+
codex: codexAdapter,
|
|
1153
|
+
amp: ampAdapter,
|
|
1154
|
+
copilot: copilotAdapter,
|
|
1155
|
+
cline: clineAdapter,
|
|
1156
|
+
crush: crushAdapter,
|
|
1157
|
+
};
|
|
1158
|
+
export const KNOWN_AGENTS = new Set(Object.keys(AGENTS));
|
|
1159
|
+
// Agent types whose adapter can resolve per-message model/offset provenance.
|
|
1160
|
+
// Used by the trace resolver to filter candidate events in SQL so it never
|
|
1161
|
+
// materializes events authored by agents it could only skip in JS.
|
|
1162
|
+
export const TRACE_RESOLVABLE_AGENTS = Object.entries(AGENTS)
|
|
1163
|
+
.filter(([, adapter]) => typeof adapter.resolveTrace === "function")
|
|
1164
|
+
.map(([type]) => type);
|
|
1165
|
+
export function getAgentAdapter(agentType) {
|
|
1166
|
+
return AGENTS[agentType] ?? defaultAdapter;
|
|
1167
|
+
}
|
|
1168
|
+
//# sourceMappingURL=agents.js.map
|