@ai-dossier/core 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,6 +105,9 @@ import {
105
105
  verifyWithEd25519,
106
106
  verifyWithKms,
107
107
  loadTrustedKeys,
108
+ findTrustedIdentifier,
109
+ normalizePublicKey,
110
+ isSupportedPublicKey,
108
111
  } from '@ai-dossier/core';
109
112
  ```
110
113
 
@@ -119,7 +122,7 @@ console.log(result.valid); // true | false
119
122
 
120
123
  #### `verifyWithEd25519(content: string, signature: string, publicKey: string): VerifyResult`
121
124
 
122
- Verify an Ed25519 signature directly.
125
+ Verify an Ed25519 signature directly. `publicKey` may be SPKI PEM, raw 32-byte base64, or base64 SPKI DER.
123
126
 
124
127
  #### `verifyWithKms(content: string, signature: string, keyId: string, region?: string): Promise<VerifyResult>`
125
128
 
@@ -127,7 +130,17 @@ Verify an ECDSA-SHA-256 signature using AWS KMS.
127
130
 
128
131
  #### `loadTrustedKeys(filePath?: string): Map<string, string>`
129
132
 
130
- Load trusted public keys from a file (default: `~/.dossier/trusted-keys.txt`). Returns a map of public key to key ID.
133
+ Load trusted public keys from a file (default: `~/.dossier/trusted-keys.txt`), format `<public-key> <identifier>` one per line. Returns a map of public key to identifier, indexed under both the written and the normalized form. Unreadable lines are reported to stderr, not dropped silently.
134
+
135
+ #### `findTrustedIdentifier(trustedKeys, signature): string | undefined`
136
+
137
+ Resolve the identifier a signature is trusted under. Use this rather than probing the map by hand — it consults `key_id` only when there is no `public_key`, which is what stops a dossier from claiming a trusted signer's identity while verifying under a different key.
138
+
139
+ #### `normalizePublicKey(key) / isSupportedPublicKey(key)`
140
+
141
+ Reduce an Ed25519 key to its canonical raw 32-byte base64 form, and test whether a key is one this project can verify against. Validate with `isSupportedPublicKey` before *storing* a key: `normalizePublicKey` deliberately passes uninterpretable input through.
142
+
143
+ See [core API reference](../../docs/reference/core-api.md#key-format-and-trust-file-helpers) for the full trust-file helper set.
131
144
 
132
145
  ### Linting
133
146
 
@@ -192,6 +205,38 @@ const verifier = registry.get('ed25519');
192
205
  const result = await verifier.verify(content, signature);
193
206
  ```
194
207
 
208
+ ### Agent Usage & Run Log
209
+
210
+ Shared with `packages/sched` so both the `ai-dossier run` headless path and the
211
+ scheduler's detached dispatch path agree on which block of an agent's result is the
212
+ source of record.
213
+
214
+ ```typescript
215
+ import {
216
+ parseAgentUsage, // claude: one --output-format json object, OR a stream-json event stream
217
+ parseOpenCodeUsage, // opencode: a `run --format json` JSONL event stream
218
+ usageParserFor, // pick the parser from the spawned binary's basename
219
+ runsLogPath, // ~/.dossier/runs.jsonl
220
+ SCHED_DISPATCH_EVENT, // the sched dispatch-log preamble `type`, skipped by both parsers
221
+ type AgentRunUsage,
222
+ type RunLogEntry,
223
+ } from '@ai-dossier/core';
224
+
225
+ const usage = parseAgentUsage(stdout);
226
+ // → { model, input_tokens, output_tokens, cache_creation_tokens,
227
+ // cache_read_tokens, total_cost_usd, result_text } — every field null
228
+ // when the agent did not report it; values are never fabricated.
229
+ ```
230
+
231
+ `parseAgentUsage` treats the agent's per-model **`modelUsage` map as the source of
232
+ record** whenever it carries at least one object-shaped entry, summed across models. The
233
+ top-level `usage` block is used only when `modelUsage` has no such entry — the two are
234
+ never blended field-by-field, because they have been observed to disagree enough to
235
+ fabricate a ~43% "saving" when mixed (ai-dossier#524). Cost is read from `costUSD` (the
236
+ key claude writes), falling back to the top-level total when `modelUsage` reports no cost
237
+ at all. For a `stream-json` log the final `type:"result"` event wins; with no such event
238
+ (an agent killed mid-run) per-turn `assistant` usage is summed instead.
239
+
195
240
  ## Types
196
241
 
197
242
  All TypeScript types are exported from the package root:
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Parsers for headless-agent JSON result output — the token/cost/model data
3
+ * an `ai-dossier run` or `packages/sched` dispatch needs to record in
4
+ * `runs.jsonl` (#458, #524).
5
+ *
6
+ * Shared between `cli` (the `ai-dossier run` headless path) and `sched` (the
7
+ * scheduler's detached-agent dispatch path) so both consumers agree on which
8
+ * block of a claude/opencode result is the source of record — the divergence
9
+ * between them (one reading `usage`, the other `modelUsage`, and disagreeing
10
+ * enough to fabricate a ~43% "saving" when mixed, ai-dossier#524) is exactly
11
+ * the bug this module exists to close off. Lives in `core`, not `cli`,
12
+ * because `sched` cannot depend on `cli` (the dependency runs the other way:
13
+ * `cli` already depends on both `core` and `sched`).
14
+ */
15
+ /**
16
+ * Usage data extracted from an agent CLI's JSON result output.
17
+ * Every field is null when the CLI did not report it — values are never
18
+ * fabricated or estimated.
19
+ */
20
+ export interface AgentRunUsage {
21
+ /** Model id the agent reported; comma-joined when several models ran (token/cost fields are totals across all). */
22
+ model: string | null;
23
+ input_tokens: number | null;
24
+ output_tokens: number | null;
25
+ /** Cache-creation (write) input tokens, summed across models when several ran. */
26
+ cache_creation_tokens: number | null;
27
+ /** Cache-read input tokens, summed across models when several ran. */
28
+ cache_read_tokens: number | null;
29
+ total_cost_usd: number | null;
30
+ /** The final result text (claude's `result` field), for re-emitting to stdout. */
31
+ result_text: string | null;
32
+ }
33
+ /**
34
+ * `type` of the preamble line `packages/sched` writes to a dispatch log at
35
+ * spawn time (ai-dossier#524). It exists so a log is never 0 bytes for a unit
36
+ * that ran; every parser here skips it rather than counting it as agent
37
+ * output. Shared from `core` so the writer (`sched`'s `createSpawnDeps`) and
38
+ * the readers (below) can never drift apart on the sentinel's spelling.
39
+ */
40
+ export declare const SCHED_DISPATCH_EVENT = "sched-dispatch";
41
+ /**
42
+ * Parse a claude headless result into usage data — both output formats.
43
+ *
44
+ * Accepts either shape, because the two consumers spawn claude differently:
45
+ *
46
+ * - **A single JSON object** (`--output-format json`, what `ai-dossier run`
47
+ * uses): parsed whole.
48
+ * - **A JSONL event stream** (`--output-format stream-json`, what the
49
+ * scheduler dispatches with since ai-dossier#524): the LAST `type:"result"`
50
+ * event wins — a per-unit log is append-mode, so a prior dispatch's result
51
+ * may precede this one in the same slice. With no `result` event at all (an
52
+ * agent killed mid-run), per-turn `assistant` usage is summed instead, so an
53
+ * interrupted dispatch still reports the tokens it really spent.
54
+ *
55
+ * Lines that do not parse as JSON objects are skipped rather than
56
+ * disqualifying the stream: the sched dispatch preamble
57
+ * ({@link SCHED_DISPATCH_EVENT}) sits at the head of every dispatch slice, and
58
+ * an agent killed mid-write leaves a truncated final line.
59
+ *
60
+ * Returns null when the output parses as neither shape (and for a stream
61
+ * holding no usage-bearing event). A result object that simply reported no
62
+ * usage yields an entry whose fields are all null, not `null` itself —
63
+ * "the agent reported nothing" and "there was no agent output" stay distinct.
64
+ */
65
+ export declare function parseAgentUsage(stdout: string | null | undefined): AgentRunUsage | null;
66
+ /**
67
+ * Parse an `opencode run --format json` result stream into usage data (#459).
68
+ *
69
+ * opencode emits one JSON event per line: the assistant's text arrives in
70
+ * `type:"text"` parts, and per-step token/cost totals in `type:"step_finish"`
71
+ * parts (a multi-step run emits several — tokens and cost are summed), with
72
+ * cache counts nested as `tokens.cache: { write, read }`. The model id is not
73
+ * present in the events, so `model` is null and callers fall back to the
74
+ * requested --model alias — that field, and only that field, is genuinely
75
+ * unavailable here rather than merely absent. Returns null when the output
76
+ * holds no opencode event at all; individual non-JSON lines (stderr warnings
77
+ * interleaved by the scheduler's merged stdout/stderr fd) are skipped, not
78
+ * treated as disqualifying.
79
+ *
80
+ * **Cross-agent caveat.** opencode reports `tokens.reasoning` SEPARATELY from
81
+ * `tokens.output` (a step's `total` is input + output + reasoning +
82
+ * cache.read), whereas claude folds thinking tokens into `output_tokens`.
83
+ * Reasoning is 45-75% of generated tokens in real logs, so an opencode
84
+ * `output_tokens` is NOT directly comparable to a claude one — do not put the
85
+ * two in the same column of a cost comparison without saying so. It is left
86
+ * out rather than summed in because this module never derives a value the
87
+ * agent did not report; capturing it needs a field of its own on
88
+ * {@link AgentRunUsage} and `RunLogEntry`, which is a schema change beyond
89
+ * ai-dossier#524's scope.
90
+ */
91
+ export declare function parseOpenCodeUsage(stdout: string | null | undefined): AgentRunUsage | null;
92
+ /**
93
+ * Pick the usage parser for a dispatched agent, keyed off the spawned
94
+ * binary's basename — `opencode` (any path) routes to
95
+ * {@link parseOpenCodeUsage}; everything else, including an
96
+ * operator-configured command this build doesn't specifically recognize,
97
+ * falls back to {@link parseAgentUsage} (the claude shape). A command this
98
+ * build doesn't recognize is not guessed at further than that fallback —
99
+ * its output either parses as a claude-shaped JSON result or the parser
100
+ * returns null, never a fabricated guess.
101
+ */
102
+ export declare function usageParserFor(cmd0: string): (stdout: string | null | undefined) => AgentRunUsage | null;
103
+ //# sourceMappingURL=agent-usage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-usage.d.ts","sourceRoot":"","sources":["../src/agent-usage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,mHAAmH;IACnH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,kFAAkF;IAClF,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,sEAAsE;IACtE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kFAAkF;IAClF,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAsFD;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,mBAAmB,CAAC;AAoLrD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,aAAa,GAAG,IAAI,CA6BvF;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,aAAa,GAAG,IAAI,CA6F1F;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,GACX,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,KAAK,aAAa,GAAG,IAAI,CAE7D"}
@@ -0,0 +1,493 @@
1
+ "use strict";
2
+ /**
3
+ * Parsers for headless-agent JSON result output — the token/cost/model data
4
+ * an `ai-dossier run` or `packages/sched` dispatch needs to record in
5
+ * `runs.jsonl` (#458, #524).
6
+ *
7
+ * Shared between `cli` (the `ai-dossier run` headless path) and `sched` (the
8
+ * scheduler's detached-agent dispatch path) so both consumers agree on which
9
+ * block of a claude/opencode result is the source of record — the divergence
10
+ * between them (one reading `usage`, the other `modelUsage`, and disagreeing
11
+ * enough to fabricate a ~43% "saving" when mixed, ai-dossier#524) is exactly
12
+ * the bug this module exists to close off. Lives in `core`, not `cli`,
13
+ * because `sched` cannot depend on `cli` (the dependency runs the other way:
14
+ * `cli` already depends on both `core` and `sched`).
15
+ */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.SCHED_DISPATCH_EVENT = void 0;
51
+ exports.parseAgentUsage = parseAgentUsage;
52
+ exports.parseOpenCodeUsage = parseOpenCodeUsage;
53
+ exports.usageParserFor = usageParserFor;
54
+ const path = __importStar(require("node:path"));
55
+ /**
56
+ * Largest token count accepted from an agent (#524). Well above any real run
57
+ * (a 1M-token context ×1000 turns), and low enough that summing a cohort of
58
+ * them in `sched stats` cannot reach Infinity.
59
+ */
60
+ const MAX_REPORTED_TOKENS = 1e15;
61
+ /** Largest cost in USD accepted from an agent (#524) — no real dispatch approaches it. */
62
+ const MAX_REPORTED_COST_USD = 1e6;
63
+ /**
64
+ * Narrow untrusted agent output to a finite, non-negative number within a
65
+ * sane ceiling (#524). Used for token counts AND for costs — hence the
66
+ * explicit `max`, since the two have very different plausible ranges.
67
+ *
68
+ * The ceiling is not paranoia: `sched stats` sums these across a cohort, and
69
+ * a single `1e308` entry would carry the whole TOTAL row to Infinity, which
70
+ * the formatter renders as `-` — silently blanking every legitimate run
71
+ * alongside it. Out-of-range values are rejected (null = "not reported")
72
+ * rather than clamped, so a bogus number is never presented as a real one.
73
+ */
74
+ function toBounded(value, max) {
75
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max
76
+ ? value
77
+ : null;
78
+ }
79
+ /** Token-count flavour of {@link toBounded}. */
80
+ function toCount(value) {
81
+ return toBounded(value, MAX_REPORTED_TOKENS);
82
+ }
83
+ /** Cost flavour of {@link toBounded} — a USD amount, not a count. */
84
+ function toCost(value) {
85
+ return toBounded(value, MAX_REPORTED_COST_USD);
86
+ }
87
+ /** Longest `model` value written to `runs.jsonl` before truncation (#524). */
88
+ const MAX_MODEL_LENGTH = 200;
89
+ /**
90
+ * Most model names joined into one `model` value (#524). The map/stream those
91
+ * names come from is agent-controlled and unbounded, so the join is capped
92
+ * before {@link sanitizeModel} ever sees it — 16 distinct models in one run
93
+ * is already far beyond anything real.
94
+ */
95
+ const MAX_MODELS_JOINED = 16;
96
+ /** Join model names for the `model` field, bounded by {@link MAX_MODELS_JOINED}. */
97
+ function joinModels(names) {
98
+ if (names.length === 0)
99
+ return null;
100
+ if (names.length === 1)
101
+ return names[0];
102
+ return names.slice(0, MAX_MODELS_JOINED).join(',');
103
+ }
104
+ /**
105
+ * `model` is copied verbatim from untrusted agent JSON into a `runs.jsonl`
106
+ * entry a later command (`ai-dossier history`, `sched stats`) may render.
107
+ * Strip control characters (the terminal-escape/log-injection risk) and cap
108
+ * the length, rather than trusting an agent-controlled string unbounded.
109
+ */
110
+ function sanitizeModel(value) {
111
+ if (value === null)
112
+ return null;
113
+ let clean = '';
114
+ // Bound the WORK, not just the result: `value` is agent-controlled and can
115
+ // be arbitrarily long, so stop as soon as the cap is reached rather than
116
+ // materializing a sanitized copy of the whole string first.
117
+ for (const char of value) {
118
+ if (clean.length >= MAX_MODEL_LENGTH)
119
+ break;
120
+ const code = char.codePointAt(0) ?? 0;
121
+ // C0 (< 0x20), DEL (0x7f) and C1 (0x80-0x9f) alike: U+009B is the 8-bit
122
+ // CSI, which a terminal in a non-UTF-8 locale acts on exactly like ESC[.
123
+ if (code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f))
124
+ clean += char;
125
+ }
126
+ return clean;
127
+ }
128
+ /** Narrow an unknown value to a plain-object record; null for anything else. */
129
+ function asRecord(value) {
130
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
131
+ ? value
132
+ : null;
133
+ }
134
+ /**
135
+ * `type` of the preamble line `packages/sched` writes to a dispatch log at
136
+ * spawn time (ai-dossier#524). It exists so a log is never 0 bytes for a unit
137
+ * that ran; every parser here skips it rather than counting it as agent
138
+ * output. Shared from `core` so the writer (`sched`'s `createSpawnDeps`) and
139
+ * the readers (below) can never drift apart on the sentinel's spelling.
140
+ */
141
+ exports.SCHED_DISPATCH_EVENT = 'sched-dispatch';
142
+ /**
143
+ * Event `type`s that only ever appear INSIDE a stream, never as a whole
144
+ * `--output-format json` payload. A single such line must be parsed as a
145
+ * one-line stream, not mistaken for a result object (#524 review).
146
+ */
147
+ const STREAM_EVENT_TYPES = new Set([
148
+ 'assistant',
149
+ 'user',
150
+ 'system',
151
+ exports.SCHED_DISPATCH_EVENT,
152
+ ]);
153
+ /** Parse one string as a JSON object; null for anything else (array, scalar, malformed). */
154
+ function parseJsonObject(text) {
155
+ try {
156
+ return asRecord(JSON.parse(text));
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ /**
163
+ * Extract usage from a claude `result`-shaped object — the payload of
164
+ * `--output-format json`, and of the final `type:"result"` event of
165
+ * `--output-format stream-json`. Both carry the same fields.
166
+ *
167
+ * `modelUsage` (summed across every model entry) is the source of record for
168
+ * token counts and cost — it is the only block that reflects a multi-model
169
+ * run accurately. The top-level `usage` / `total_cost_usd` (older: `cost_usd`)
170
+ * fields are used ONLY when `modelUsage` has no object-shaped entry at all:
171
+ * they are never blended field-by-field with it, because the two blocks have been
172
+ * observed to disagree (ai-dossier#524) — picking one field from each would
173
+ * silently produce a number neither block actually reported.
174
+ */
175
+ function extractResultUsage(parsed) {
176
+ const usage = asRecord(parsed.usage) ?? {};
177
+ const modelUsage = asRecord(parsed.modelUsage);
178
+ // Keep only object-shaped entries; a scalar entry is malformed, not a model.
179
+ const modelEntries = modelUsage
180
+ ? Object.entries(modelUsage).filter((entry) => !!asRecord(entry[1]))
181
+ : [];
182
+ const hasModelUsage = modelEntries.length > 0;
183
+ /** Sum one field across every model entry, trying each spelling in turn. */
184
+ const sumFromModelUsage = (keys, narrow = toCount) => {
185
+ let sum = 0;
186
+ let seen = false;
187
+ for (const [, entry] of modelEntries) {
188
+ let value = null;
189
+ for (const key of keys) {
190
+ value = narrow(entry[key]);
191
+ if (value !== null)
192
+ break;
193
+ }
194
+ if (value !== null) {
195
+ sum += value;
196
+ seen = true;
197
+ }
198
+ }
199
+ return seen ? sum : null;
200
+ };
201
+ // modelUsage wins whenever it carries at least one object-shaped entry —
202
+ // even if that entry only answers some of the fields — so a run's numbers
203
+ // always come from a single, consistent block rather than a per-field blend
204
+ // with `usage`. (`modelUsage: {}`, or a map whose every entry is a scalar,
205
+ // is malformed rather than present: `usage` is used in that case.)
206
+ const input_tokens = hasModelUsage
207
+ ? sumFromModelUsage(['inputTokens', 'input_tokens'])
208
+ : toCount(usage.input_tokens);
209
+ const output_tokens = hasModelUsage
210
+ ? sumFromModelUsage(['outputTokens', 'output_tokens'])
211
+ : toCount(usage.output_tokens);
212
+ const cache_creation_tokens = hasModelUsage
213
+ ? sumFromModelUsage(['cacheCreationInputTokens', 'cache_creation_input_tokens'])
214
+ : toCount(usage.cache_creation_input_tokens);
215
+ const cache_read_tokens = hasModelUsage
216
+ ? sumFromModelUsage(['cacheReadInputTokens', 'cache_read_input_tokens'])
217
+ : toCount(usage.cache_read_input_tokens);
218
+ // `costUSD` FIRST: that is the key claude actually writes inside a
219
+ // `modelUsage` entry (verified against a live claude stats artifact, #524
220
+ // review). `totalCostUsd`/`total_cost_usd` are kept as tolerated spellings.
221
+ // When modelUsage reports no cost key at all, fall back to the top-level
222
+ // total rather than reporting null — the whole-run cost is not a per-field
223
+ // blend of two token blocks, it is the one number both shapes agree on, and
224
+ // dropping it silently regressed `ai-dossier run`'s existing cost recording.
225
+ const costFromModelUsage = hasModelUsage
226
+ ? sumFromModelUsage(['costUSD', 'totalCostUsd', 'total_cost_usd'], toCost)
227
+ : null;
228
+ const total_cost_usd = costFromModelUsage ?? toCost(parsed.total_cost_usd) ?? toCost(parsed.cost_usd);
229
+ const modelFromUsage = joinModels(modelEntries.map(([key]) => key));
230
+ const rawModel = typeof parsed.model === 'string' && parsed.model ? parsed.model : modelFromUsage;
231
+ const model = sanitizeModel(rawModel);
232
+ const result_text = typeof parsed.result === 'string' ? parsed.result : null;
233
+ return {
234
+ model,
235
+ input_tokens,
236
+ output_tokens,
237
+ cache_creation_tokens,
238
+ cache_read_tokens,
239
+ total_cost_usd,
240
+ result_text,
241
+ };
242
+ }
243
+ /**
244
+ * Sum per-turn usage from `type:"assistant"` stream events — the fallback for
245
+ * a run that produced NO final `result` event (ai-dossier#524).
246
+ *
247
+ * The scheduler kills an agent that is still alive when ground truth says its
248
+ * unit is already done (`reconcileRunning`'s external-advance branch), and a
249
+ * killed agent never emits its `result` event. Before stream-json that meant
250
+ * zero recoverable tokens for those dispatches — six of the eleven pilot units.
251
+ * Each `assistant` event carries the usage of the request that produced it, so
252
+ * summing them recovers what the run actually spent up to the kill.
253
+ *
254
+ * `total_cost_usd` stays null: per-turn events do not report cost, and this
255
+ * path must not fabricate one from token counts.
256
+ */
257
+ function sumAssistantUsage(events) {
258
+ let input = 0;
259
+ let output = 0;
260
+ let cacheCreation = 0;
261
+ let cacheRead = 0;
262
+ let sawInput = false;
263
+ let sawOutput = false;
264
+ let sawCacheCreation = false;
265
+ let sawCacheRead = false;
266
+ const models = new Set();
267
+ for (const event of events) {
268
+ const message = asRecord(event.message);
269
+ if (!message)
270
+ continue;
271
+ if (typeof message.model === 'string' && message.model)
272
+ models.add(message.model);
273
+ const usage = asRecord(message.usage);
274
+ if (!usage)
275
+ continue;
276
+ const inputTokens = toCount(usage.input_tokens);
277
+ if (inputTokens !== null) {
278
+ input += inputTokens;
279
+ sawInput = true;
280
+ }
281
+ const outputTokens = toCount(usage.output_tokens);
282
+ if (outputTokens !== null) {
283
+ output += outputTokens;
284
+ sawOutput = true;
285
+ }
286
+ const cacheCreationTokens = toCount(usage.cache_creation_input_tokens);
287
+ if (cacheCreationTokens !== null) {
288
+ cacheCreation += cacheCreationTokens;
289
+ sawCacheCreation = true;
290
+ }
291
+ const cacheReadTokens = toCount(usage.cache_read_input_tokens);
292
+ if (cacheReadTokens !== null) {
293
+ cacheRead += cacheReadTokens;
294
+ sawCacheRead = true;
295
+ }
296
+ }
297
+ return {
298
+ model: sanitizeModel(joinModels([...models])),
299
+ input_tokens: sawInput ? input : null,
300
+ output_tokens: sawOutput ? output : null,
301
+ cache_creation_tokens: sawCacheCreation ? cacheCreation : null,
302
+ cache_read_tokens: sawCacheRead ? cacheRead : null,
303
+ total_cost_usd: null,
304
+ result_text: null,
305
+ };
306
+ }
307
+ /**
308
+ * Parse a claude headless result into usage data — both output formats.
309
+ *
310
+ * Accepts either shape, because the two consumers spawn claude differently:
311
+ *
312
+ * - **A single JSON object** (`--output-format json`, what `ai-dossier run`
313
+ * uses): parsed whole.
314
+ * - **A JSONL event stream** (`--output-format stream-json`, what the
315
+ * scheduler dispatches with since ai-dossier#524): the LAST `type:"result"`
316
+ * event wins — a per-unit log is append-mode, so a prior dispatch's result
317
+ * may precede this one in the same slice. With no `result` event at all (an
318
+ * agent killed mid-run), per-turn `assistant` usage is summed instead, so an
319
+ * interrupted dispatch still reports the tokens it really spent.
320
+ *
321
+ * Lines that do not parse as JSON objects are skipped rather than
322
+ * disqualifying the stream: the sched dispatch preamble
323
+ * ({@link SCHED_DISPATCH_EVENT}) sits at the head of every dispatch slice, and
324
+ * an agent killed mid-write leaves a truncated final line.
325
+ *
326
+ * Returns null when the output parses as neither shape (and for a stream
327
+ * holding no usage-bearing event). A result object that simply reported no
328
+ * usage yields an entry whose fields are all null, not `null` itself —
329
+ * "the agent reported nothing" and "there was no agent output" stay distinct.
330
+ */
331
+ function parseAgentUsage(stdout) {
332
+ if (typeof stdout !== 'string' || stdout.trim() === '')
333
+ return null;
334
+ // `--output-format json`: the whole payload is one object. Checked first so
335
+ // a pretty-printed (multi-line) result is not mistaken for an event stream.
336
+ // Stream event types are excluded, or a stream that happens to hold exactly
337
+ // ONE line (an agent killed after a single turn, or whose preamble write
338
+ // failed) would take this path and report all-null instead of falling
339
+ // through to the per-turn sum below.
340
+ const single = parseJsonObject(stdout);
341
+ if (single && !STREAM_EVENT_TYPES.has(single.type))
342
+ return extractResultUsage(single);
343
+ let lastResult = null;
344
+ const assistants = [];
345
+ for (const line of stdout.split('\n')) {
346
+ const trimmed = line.trim();
347
+ if (!trimmed)
348
+ continue;
349
+ const event = parseJsonObject(trimmed);
350
+ if (!event || event.type === exports.SCHED_DISPATCH_EVENT)
351
+ continue;
352
+ if (event.type === 'result')
353
+ lastResult = event;
354
+ else if (event.type === 'assistant')
355
+ assistants.push(event);
356
+ }
357
+ if (lastResult)
358
+ return extractResultUsage(lastResult);
359
+ if (assistants.length > 0)
360
+ return sumAssistantUsage(assistants);
361
+ return null;
362
+ }
363
+ /**
364
+ * Parse an `opencode run --format json` result stream into usage data (#459).
365
+ *
366
+ * opencode emits one JSON event per line: the assistant's text arrives in
367
+ * `type:"text"` parts, and per-step token/cost totals in `type:"step_finish"`
368
+ * parts (a multi-step run emits several — tokens and cost are summed), with
369
+ * cache counts nested as `tokens.cache: { write, read }`. The model id is not
370
+ * present in the events, so `model` is null and callers fall back to the
371
+ * requested --model alias — that field, and only that field, is genuinely
372
+ * unavailable here rather than merely absent. Returns null when the output
373
+ * holds no opencode event at all; individual non-JSON lines (stderr warnings
374
+ * interleaved by the scheduler's merged stdout/stderr fd) are skipped, not
375
+ * treated as disqualifying.
376
+ *
377
+ * **Cross-agent caveat.** opencode reports `tokens.reasoning` SEPARATELY from
378
+ * `tokens.output` (a step's `total` is input + output + reasoning +
379
+ * cache.read), whereas claude folds thinking tokens into `output_tokens`.
380
+ * Reasoning is 45-75% of generated tokens in real logs, so an opencode
381
+ * `output_tokens` is NOT directly comparable to a claude one — do not put the
382
+ * two in the same column of a cost comparison without saying so. It is left
383
+ * out rather than summed in because this module never derives a value the
384
+ * agent did not report; capturing it needs a field of its own on
385
+ * {@link AgentRunUsage} and `RunLogEntry`, which is a schema change beyond
386
+ * ai-dossier#524's scope.
387
+ */
388
+ function parseOpenCodeUsage(stdout) {
389
+ if (typeof stdout !== 'string' || stdout.trim() === '')
390
+ return null;
391
+ let sawEvent = false;
392
+ const texts = [];
393
+ let inputTokens = 0;
394
+ let outputTokens = 0;
395
+ let cacheCreationTokens = 0;
396
+ let cacheReadTokens = 0;
397
+ let costUsd = 0;
398
+ let sawUsage = false;
399
+ let sawCacheCreation = false;
400
+ let sawCacheRead = false;
401
+ for (const line of stdout.split('\n')) {
402
+ const trimmed = line.trim();
403
+ if (!trimmed)
404
+ continue;
405
+ const event = parseJsonObject(trimmed);
406
+ // Skip an unparseable line rather than discarding the whole stream
407
+ // (#524 review). `createSpawnDeps` merges stderr into the SAME fd as
408
+ // stdout, and opencode writes ANSI-coloured warnings there ("permission
409
+ // requested: ... auto-rejecting"), so ONE such line in a 1,600-line log
410
+ // used to null out the entire run — ~$28 and ~97M cache-read tokens
411
+ // across two real logs on a six-run cohort, recorded as indistinguishable
412
+ // nulls. Format-mismatch detection is preserved by `sawEvent` below: a
413
+ // stream with no valid opencode event at all still returns null.
414
+ if (!event)
415
+ continue;
416
+ // The sched dispatch preamble (#524) is written by the scheduler, not by
417
+ // opencode: skip it WITHOUT setting `sawEvent`, so a log holding only a
418
+ // preamble still reads as "the agent wrote nothing" (null) rather than an
419
+ // all-null usage object implying opencode reported no tokens.
420
+ if (event.type === exports.SCHED_DISPATCH_EVENT)
421
+ continue;
422
+ sawEvent = true;
423
+ const part = asRecord(event.part);
424
+ if (event.type === 'text' && part && typeof part.text === 'string') {
425
+ texts.push(part.text);
426
+ }
427
+ if (event.type === 'step_finish' && part) {
428
+ const tokens = asRecord(part.tokens);
429
+ if (tokens) {
430
+ const input = toCount(tokens.input);
431
+ if (input !== null) {
432
+ inputTokens += input;
433
+ sawUsage = true;
434
+ }
435
+ const output = toCount(tokens.output);
436
+ if (output !== null) {
437
+ outputTokens += output;
438
+ sawUsage = true;
439
+ }
440
+ // opencode nests cache counts one level down, as
441
+ // `tokens.cache: { write, read }` — verified against a real 310-event
442
+ // dispatch log (#524 review). They were previously hardcoded null on
443
+ // the mistaken belief opencode did not report them, silently dropping
444
+ // tens of millions of cache-read tokens per run.
445
+ const cache = asRecord(tokens.cache);
446
+ if (cache) {
447
+ const write = toCount(cache.write);
448
+ if (write !== null) {
449
+ cacheCreationTokens += write;
450
+ sawCacheCreation = true;
451
+ sawUsage = true;
452
+ }
453
+ const read = toCount(cache.read);
454
+ if (read !== null) {
455
+ cacheReadTokens += read;
456
+ sawCacheRead = true;
457
+ sawUsage = true;
458
+ }
459
+ }
460
+ }
461
+ const cost = toCost(part.cost);
462
+ if (cost !== null) {
463
+ costUsd += cost;
464
+ sawUsage = true;
465
+ }
466
+ }
467
+ }
468
+ if (!sawEvent)
469
+ return null;
470
+ return {
471
+ model: null,
472
+ input_tokens: sawUsage ? inputTokens : null,
473
+ output_tokens: sawUsage ? outputTokens : null,
474
+ cache_creation_tokens: sawCacheCreation ? cacheCreationTokens : null,
475
+ cache_read_tokens: sawCacheRead ? cacheReadTokens : null,
476
+ total_cost_usd: sawUsage ? costUsd : null,
477
+ result_text: texts.length > 0 ? texts.join('') : null,
478
+ };
479
+ }
480
+ /**
481
+ * Pick the usage parser for a dispatched agent, keyed off the spawned
482
+ * binary's basename — `opencode` (any path) routes to
483
+ * {@link parseOpenCodeUsage}; everything else, including an
484
+ * operator-configured command this build doesn't specifically recognize,
485
+ * falls back to {@link parseAgentUsage} (the claude shape). A command this
486
+ * build doesn't recognize is not guessed at further than that fallback —
487
+ * its output either parses as a claude-shaped JSON result or the parser
488
+ * returns null, never a fabricated guess.
489
+ */
490
+ function usageParserFor(cmd0) {
491
+ return path.basename(cmd0) === 'opencode' ? parseOpenCodeUsage : parseAgentUsage;
492
+ }
493
+ //# sourceMappingURL=agent-usage.js.map