@bglocation/tune-context 2.3.1 → 2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +193 -0
- package/README.md +188 -6
- package/bin/tune-context.mjs +241 -15
- package/cli/experiment.mjs +674 -0
- package/cli/managed-block.mjs +24 -0
- package/cli/verify.mjs +51 -4
- package/eval/adoption-report.mjs +9 -7
- package/eval/analyze.mjs +689 -0
- package/eval/arm.mjs +246 -0
- package/eval/pricing.mjs +461 -0
- package/eval/prompt-metrics.mjs +1046 -0
- package/eval/report.mjs +870 -0
- package/eval/verdict.mjs +639 -0
- package/hooks/adoption-log.mjs +1 -1
- package/hooks/context-reminder.mjs +1 -1
- package/hooks/pre-compact-snapshot.mjs +4 -4
- package/hooks/session-start-reinject.mjs +1 -4
- package/package.json +7 -2
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// prompt-metrics — Claude Code transcripts → one JSONL row per prompt: time,
|
|
3
|
+
// tokens (split by cache class), model/effort, tools, delegations. The raw
|
|
4
|
+
// components EPIC-009 prices in TASK-086 and compares per arm in TASK-089.
|
|
5
|
+
// See tasks/TASK-085.
|
|
6
|
+
//
|
|
7
|
+
// Why the transcript and not a hook: in the "tune-context off" arm our hooks do
|
|
8
|
+
// not run, so hook instrumentation cannot measure the reference arm — by
|
|
9
|
+
// construction. The transcript is written by Claude Code itself, so it is the
|
|
10
|
+
// only source that works in BOTH arms. It also already exists for past sessions,
|
|
11
|
+
// which is why this task runs first in the epic: baseline before any switch.
|
|
12
|
+
//
|
|
13
|
+
// Honest scope (the CLI prints the same facts to stderr on every run):
|
|
14
|
+
// • `list_cost_usd` (eval/pricing.mjs, TASK-086) is API LIST PRICE for these
|
|
15
|
+
// tokens — NOT a bill. A Pro/Max subscription charges its flat fee and this
|
|
16
|
+
// figure is what the same work would have cost through the API. A model
|
|
17
|
+
// missing from the price table yields null and an `unpriced` count, never a
|
|
18
|
+
// zero.
|
|
19
|
+
// • Server-side tool use is billed per request, not per token, so it is not
|
|
20
|
+
// inside list_cost_usd. Web search costs $10/1000; web FETCH is free. Both
|
|
21
|
+
// counts ride on the row. Zero on all data measured so far.
|
|
22
|
+
// • Subagent turns are NOT in the parent transcript. Measured 2026-07-30:
|
|
23
|
+
// 29 transcripts, 21 `Agent` tool calls, ZERO isSidechain entries. But the
|
|
24
|
+
// RESULT of an `Agent` call carries its own `usage` and `resolvedModel`, so
|
|
25
|
+
// the delegation is measurable FROM BELOW — see applyDelegation(). The row
|
|
26
|
+
// therefore reports two figures that are never added together: the parent
|
|
27
|
+
// session's cost, and `delegations[].list_cost_usd`, a lower bound on what
|
|
28
|
+
// the subagents cost (COST_EVALUATION_PROCEDURE §11a). `agent_spawns` and
|
|
29
|
+
// `sidechain_turns` are still counted, so the size of the remaining blind
|
|
30
|
+
// spot stays visible (EPIC-009, error class 3).
|
|
31
|
+
// • `<synthetic>` assistant entries are generated by Claude Code, not billed
|
|
32
|
+
// by the API. They are excluded from tokens/turns and counted separately —
|
|
33
|
+
// an unbillable entry, not TASK-086's "unknown model".
|
|
34
|
+
//
|
|
35
|
+
// The `usage` reading here deliberately does NOT share code with
|
|
36
|
+
// hooks/context-reminder.mjs, which also parses it. That hook answers "how big
|
|
37
|
+
// is the context right now" and sums the three input classes into one number;
|
|
38
|
+
// this file must keep them apart, because they are priced at 1x / 0.1x /
|
|
39
|
+
// 1.25x-2x. One helper serving both would have to pick a side. (A hook also
|
|
40
|
+
// cannot import from eval/ — only hooks/ is installed.)
|
|
41
|
+
import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
|
|
42
|
+
import { createInterface } from 'node:readline';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
import { parseArgs } from 'node:util';
|
|
45
|
+
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
46
|
+
import { experimentPaths, readLedger } from '../cli/experiment.mjs';
|
|
47
|
+
import { annotateRows, formatArmSummary } from './arm.mjs';
|
|
48
|
+
import { formatCostLines, formatUsd, priceRow, summarizeCost, webSearchCost } from './pricing.mjs';
|
|
49
|
+
|
|
50
|
+
// Tool names that spawn a subagent. `Agent` is what this Claude Code writes
|
|
51
|
+
// (measured); `Task` is the name the same tool carried in earlier versions.
|
|
52
|
+
// Both are counted so a version change cannot silently zero the delegation
|
|
53
|
+
// count — the one number that lets TASK-089 say "uncaptured" instead of nothing.
|
|
54
|
+
const AGENT_TOOLS = new Set(['Agent', 'Task']);
|
|
55
|
+
|
|
56
|
+
// Model string Claude Code puts on entries it generated itself (interrupts,
|
|
57
|
+
// local notices). Never an API call, never a cost.
|
|
58
|
+
const SYNTHETIC_MODEL = '<synthetic>';
|
|
59
|
+
|
|
60
|
+
const USAGE = `
|
|
61
|
+
prompt-metrics — per-prompt time/token/tool metrics from Claude Code transcripts.
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
prompt-metrics [--transcript <path>]... [--dir <path>]
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
--transcript <path> A single .jsonl transcript. Repeatable.
|
|
68
|
+
--dir <path> Directory of transcripts, scanned one level deep. Used
|
|
69
|
+
together with --transcript, the two are unioned.
|
|
70
|
+
(default when neither is given: <config dir>/projects,
|
|
71
|
+
where <config dir> is $CLAUDE_CONFIG_DIR if set,
|
|
72
|
+
else ~/.claude)
|
|
73
|
+
-h, --help Show this help
|
|
74
|
+
|
|
75
|
+
Writes JSONL to stdout, one row per prompt. A run summary — including how many
|
|
76
|
+
entries could NOT be attributed to a prompt, and how many rows carry a model the
|
|
77
|
+
price table does not know — goes to stderr, so stdout stays a clean data channel.
|
|
78
|
+
|
|
79
|
+
Rows from a branch shaped exp/<task>/<arm> (the paired measurement, TASK-088)
|
|
80
|
+
also carry:
|
|
81
|
+
exp_task, exp_arm the arm, resolved — '' when it could not be resolved
|
|
82
|
+
exp_arm_source branch+ledger | branch | none | conflict:<why>
|
|
83
|
+
exp_ledger_arm what the TASK-087 ledger said, whenever it resolved —
|
|
84
|
+
on agreement too, so a confirmation can be audited
|
|
85
|
+
pass_order 1 for the arm run first in that task, 2 for the second
|
|
86
|
+
The branch says which arm was INTENDED, the ledger which was SET; a disagreement
|
|
87
|
+
leaves the arm unresolved rather than picking a winner.
|
|
88
|
+
|
|
89
|
+
These five fields are added here, in the CLI. Rows obtained through the module
|
|
90
|
+
(aggregate, collectFiles) carry the measurement only — call annotateRows from
|
|
91
|
+
eval/arm.mjs to add the arm.
|
|
92
|
+
|
|
93
|
+
Each row also carries delegations[]: one record per Agent call that returned its
|
|
94
|
+
own usage, priced with the same table. That figure is a LOWER BOUND on what the
|
|
95
|
+
subagents cost and is never added to the row's own cost.
|
|
96
|
+
`.trim();
|
|
97
|
+
|
|
98
|
+
/** Parse one transcript line. Returns null for blanks, junk, or non-objects. */
|
|
99
|
+
export function parseEntry(line) {
|
|
100
|
+
const t = typeof line === 'string' ? line.trim() : '';
|
|
101
|
+
if (t === '') return null;
|
|
102
|
+
try {
|
|
103
|
+
const o = JSON.parse(t);
|
|
104
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every entry of a transcript, malformed lines dropped. Pure over text — the
|
|
112
|
+
* I/O-free seam tests use. Real runs go through readLines()/collectFiles()
|
|
113
|
+
* instead, which never hold the whole file (see readLines).
|
|
114
|
+
*/
|
|
115
|
+
export function parseEntries(text) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const line of text.split('\n')) {
|
|
118
|
+
const e = parseEntry(line);
|
|
119
|
+
if (e !== null) out.push(e);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function msOf(entry) {
|
|
125
|
+
const t = Date.parse(entry?.timestamp);
|
|
126
|
+
return Number.isNaN(t) ? null : t;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isoOrNull(ms) {
|
|
130
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Elapsed seconds, 1ms precision; null when either end is unknown. */
|
|
134
|
+
function seconds(fromMs, toMs) {
|
|
135
|
+
if (fromMs === null || toMs === null) return null;
|
|
136
|
+
return Math.round(toMs - fromMs) / 1000;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A prompt may span several models/efforts/tiers (measured: one session carried
|
|
140
|
+
// claude-opus-5, claude-sonnet-5 and <synthetic>; effort took high/xhigh/max).
|
|
141
|
+
// The row keeps a single scalar for the common case and the literal string
|
|
142
|
+
// 'mixed' otherwise — never an average, never a silent first-wins. The full
|
|
143
|
+
// distinct set rides alongside (`models`, `efforts`) so 'mixed' is decodable
|
|
144
|
+
// and by_model below still prices the row exactly.
|
|
145
|
+
function collapse(set) {
|
|
146
|
+
if (set.size === 0) return null;
|
|
147
|
+
if (set.size === 1) return [...set][0];
|
|
148
|
+
return 'mixed';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function emptyTokens() {
|
|
152
|
+
return {
|
|
153
|
+
input_tokens: 0,
|
|
154
|
+
cache_read: 0,
|
|
155
|
+
cache_write_5m: 0,
|
|
156
|
+
cache_write_1h: 0,
|
|
157
|
+
// Cache-creation tokens the entry did NOT break down by TTL. Kept separate
|
|
158
|
+
// rather than folded into 5m: the write multipliers differ (1.25× vs 2×),
|
|
159
|
+
// so guessing a TTL here would quietly invent up to 60% of a cost line.
|
|
160
|
+
// Zero on every entry measured so far; if it ever isn't, TASK-086 sees it.
|
|
161
|
+
cache_write_unsplit: 0,
|
|
162
|
+
output_tokens: 0,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function addUsage(into, usage) {
|
|
167
|
+
const created = usage.cache_creation_input_tokens || 0;
|
|
168
|
+
const m5 = usage.cache_creation?.ephemeral_5m_input_tokens || 0;
|
|
169
|
+
const h1 = usage.cache_creation?.ephemeral_1h_input_tokens || 0;
|
|
170
|
+
into.input_tokens += usage.input_tokens || 0;
|
|
171
|
+
into.cache_read += usage.cache_read_input_tokens || 0;
|
|
172
|
+
into.cache_write_5m += m5;
|
|
173
|
+
into.cache_write_1h += h1;
|
|
174
|
+
into.cache_write_unsplit += Math.max(0, created - m5 - h1);
|
|
175
|
+
into.output_tokens += usage.output_tokens || 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The `tool_use` blocks an assistant entry carries, in block order. One reader
|
|
179
|
+
* for the row path and the unattributed bucket both: what counts as a tool call
|
|
180
|
+
* must not quietly become two slightly different answers. */
|
|
181
|
+
function toolUseBlocks(entry) {
|
|
182
|
+
const content = Array.isArray(entry.message?.content) ? entry.message.content : [];
|
|
183
|
+
return content.filter((block) => block?.type === 'tool_use');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Server-side tool requests carried by a usage block, as plain counts.
|
|
187
|
+
*
|
|
188
|
+
* Deliberately NOT folded into addUsage(): every field that function touches is
|
|
189
|
+
* a token class priced PER TOKEN, and these are billed PER REQUEST, which is
|
|
190
|
+
* why they sit outside list_cost_by_class on a row. addUsage also feeds the
|
|
191
|
+
* usage.iterations comparison (where a request count would be compared against
|
|
192
|
+
* token counts) and the per-model buckets published as `by_model` — a field
|
|
193
|
+
* added there would surface in a row's token breakdown, which its own test
|
|
194
|
+
* pins to exactly the five priced classes plus the unsplit one. So the shape of
|
|
195
|
+
* `usage.server_tool_use` gets ONE reader instead, and the two accumulators
|
|
196
|
+
* that need it keep their own field names. */
|
|
197
|
+
function serverToolRequests(usage) {
|
|
198
|
+
const server = usage?.server_tool_use;
|
|
199
|
+
return {
|
|
200
|
+
search: server?.web_search_requests || 0,
|
|
201
|
+
fetch: server?.web_fetch_requests || 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function newPrompt({ promptId, session }) {
|
|
206
|
+
return {
|
|
207
|
+
promptId,
|
|
208
|
+
session,
|
|
209
|
+
cwd: null,
|
|
210
|
+
gitBranch: null,
|
|
211
|
+
gitBranchChanged: false,
|
|
212
|
+
ccVersion: null,
|
|
213
|
+
tsSubmit: null,
|
|
214
|
+
tsFirstAssistant: null,
|
|
215
|
+
tsEnd: null,
|
|
216
|
+
lastTs: null,
|
|
217
|
+
maxGap: 0,
|
|
218
|
+
models: new Set(),
|
|
219
|
+
efforts: new Set(),
|
|
220
|
+
speeds: new Set(),
|
|
221
|
+
tiers: new Set(),
|
|
222
|
+
// Data residency: `us` costs 1.1x across every token class (Claude 4.6+).
|
|
223
|
+
geos: new Set(),
|
|
224
|
+
apiTurns: 0,
|
|
225
|
+
syntheticTurns: 0,
|
|
226
|
+
// No flat token totals here on purpose: the row's input_tokens/cache_*/
|
|
227
|
+
// output_tokens are DERIVED from byModel in toRow(). Two accumulators fed
|
|
228
|
+
// from the same usage block are two things that can drift, and the drift
|
|
229
|
+
// would show up as a cost that does not match its own breakdown.
|
|
230
|
+
byModel: new Map(),
|
|
231
|
+
// Turns whose `usage.iterations` says more was spent than the top-level
|
|
232
|
+
// `usage` reports — see usageUnderstated(). Known underestimation, counted
|
|
233
|
+
// rather than corrected (TASK-086).
|
|
234
|
+
multiAttemptTurns: 0,
|
|
235
|
+
understatedTurns: 0,
|
|
236
|
+
// Split because they are billed differently: web search is $10/1000, web
|
|
237
|
+
// fetch is free. Counting them together priced free requests.
|
|
238
|
+
webSearchRequests: 0,
|
|
239
|
+
webFetchRequests: 0,
|
|
240
|
+
tools: new Map(),
|
|
241
|
+
toolCalls: 0,
|
|
242
|
+
agentSpawns: 0,
|
|
243
|
+
sidechainTurns: 0,
|
|
244
|
+
// Keyed by agentId, because the same delegation result is written to
|
|
245
|
+
// SEVERAL transcript files (measured: 8 entries for 4 delegations). A list
|
|
246
|
+
// here would double the only figure we have for subagent cost.
|
|
247
|
+
delegations: new Map(),
|
|
248
|
+
delegationDuplicates: 0,
|
|
249
|
+
delegationsUnidentified: 0,
|
|
250
|
+
promptChars: null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Sum over whatever classes emptyTokens() defines — a new class cannot slip
|
|
255
|
+
* out of this total by being forgotten in a hand-written list. */
|
|
256
|
+
function totalTokens(t) {
|
|
257
|
+
return Object.values(t).reduce((a, b) => a + b, 0);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `usage.iterations` is a per-attempt breakdown that appears when a request was
|
|
262
|
+
* retried or fell back to another model. It is NOT summed with the top-level
|
|
263
|
+
* `usage`, which describes only the attempt that answered — adding both counts
|
|
264
|
+
* the same tokens twice. `usage` is the source, full stop; addUsage() reads
|
|
265
|
+
* named fields only, so it cannot drift into iterations by accident.
|
|
266
|
+
*
|
|
267
|
+
* That rule has a price: when the attempts spent more than the top level
|
|
268
|
+
* reports, the row understates. So the condition is measured rather than
|
|
269
|
+
* assumed — if the iterations add up to more than the top level, the turn is
|
|
270
|
+
* counted here and the run says so.
|
|
271
|
+
*
|
|
272
|
+
* Measured over 29 transcripts (16 931 usage blocks carrying `iterations`):
|
|
273
|
+
* 0 blocks with more than one attempt, but 3 where the top-level usage is all
|
|
274
|
+
* zeros while the single iteration carries real tokens (228k cache reads in one
|
|
275
|
+
* case). TASK-086 assumed this counter would stay at zero; it does not.
|
|
276
|
+
*/
|
|
277
|
+
function usageUnderstated(usage) {
|
|
278
|
+
const its = Array.isArray(usage.iterations) ? usage.iterations : [];
|
|
279
|
+
if (its.length === 0) return false;
|
|
280
|
+
const top = emptyTokens();
|
|
281
|
+
addUsage(top, usage);
|
|
282
|
+
const attempts = emptyTokens();
|
|
283
|
+
for (const it of its) addUsage(attempts, it);
|
|
284
|
+
return totalTokens(attempts) > totalTokens(top);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Identity fields, from the first entry of the turn that carries each one. */
|
|
288
|
+
function applyIdentity(p, entry) {
|
|
289
|
+
if (p.cwd === null && typeof entry.cwd === 'string') p.cwd = entry.cwd;
|
|
290
|
+
if (p.ccVersion === null && typeof entry.version === 'string') p.ccVersion = entry.version;
|
|
291
|
+
// gitBranch is the ARM marker in the paired measurement (TASK-088), not
|
|
292
|
+
// decoration, so a change of branch inside one turn is flagged rather than
|
|
293
|
+
// averaged away — 19 of 495 turns in the live run.
|
|
294
|
+
if (typeof entry.gitBranch === 'string') {
|
|
295
|
+
if (p.gitBranch === null) p.gitBranch = entry.gitBranch;
|
|
296
|
+
else if (p.gitBranch !== entry.gitBranch) p.gitBranchChanged = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Turn boundaries and the longest silence inside them. */
|
|
301
|
+
function applyTiming(p, entry, isAssistant) {
|
|
302
|
+
const ts = msOf(entry);
|
|
303
|
+
if (ts === null) return;
|
|
304
|
+
if (p.tsSubmit === null || ts < p.tsSubmit) p.tsSubmit = ts;
|
|
305
|
+
if (p.tsEnd === null || ts > p.tsEnd) p.tsEnd = ts;
|
|
306
|
+
if (isAssistant && (p.tsFirstAssistant === null || ts < p.tsFirstAssistant)) p.tsFirstAssistant = ts;
|
|
307
|
+
// Largest silence between two consecutive entries of this turn. Same rule as
|
|
308
|
+
// the queued_s/work_s split, applied INSIDE the turn: a tool call waiting on
|
|
309
|
+
// a permission prompt is the user's time, not the tool's, and it lands in
|
|
310
|
+
// work_s indistinguishably. Not hypothetical — the live run over 29
|
|
311
|
+
// transcripts has a turn with work_s = 158999 (44 h). Measured here, judged
|
|
312
|
+
// in TASK-089: this file reports, it does not filter.
|
|
313
|
+
if (p.lastTs !== null && ts > p.lastTs) {
|
|
314
|
+
p.maxGap = Math.max(p.maxGap, ts - p.lastTs);
|
|
315
|
+
p.lastTs = ts;
|
|
316
|
+
} else if (p.lastTs === null) {
|
|
317
|
+
p.lastTs = ts;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* prompt_chars: what the USER sent. The first non-meta user entry that is not a
|
|
323
|
+
* tool result — later user entries in the group ARE tool results (934 of 1012
|
|
324
|
+
* in the measured session) and are not the prompt.
|
|
325
|
+
*/
|
|
326
|
+
function applyUserEntry(p, entry) {
|
|
327
|
+
if (p.promptChars !== null || entry.isMeta === true) return;
|
|
328
|
+
const content = entry.message?.content;
|
|
329
|
+
if (typeof content === 'string') p.promptChars = content.length;
|
|
330
|
+
else if (Array.isArray(content) && !content.some((b) => b?.type === 'tool_result')) {
|
|
331
|
+
p.promptChars = content.reduce((n, b) => n + (b?.type === 'text' ? (b.text?.length ?? 0) : 0), 0);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The subagent's own bill, off the `Agent` tool RESULT.
|
|
337
|
+
*
|
|
338
|
+
* A delegation leaves no turns in the parent transcript (0 `isSidechain`
|
|
339
|
+
* entries over the whole of `~/.claude/projects`), but the result entry carries
|
|
340
|
+
* `toolUseResult.usage` — the same structure as `message.usage`, cache split
|
|
341
|
+
* included — plus `resolvedModel`. So it is priced with the SAME `priceRow`
|
|
342
|
+
* that prices the parent turn: TASK-089 criterion 8 asks for no new pricing
|
|
343
|
+
* logic, and a second code path to dollars is a second set of prices.
|
|
344
|
+
*
|
|
345
|
+
* Three rules this obeys, all from COST_EVALUATION_PROCEDURE §11a:
|
|
346
|
+
*
|
|
347
|
+
* • **Deduplicated by `agentId`.** The result entry is written twice (8
|
|
348
|
+
* entries = 4 delegations, measured) — and, measured 2026-08-02, WITHIN one
|
|
349
|
+
* file rather than across files as first assumed, sometimes under a
|
|
350
|
+
* different `promptId`, which puts one delegation on two rows. The entry
|
|
351
|
+
* dedup in addEntry() removes the byte-identical copies; this keeps the
|
|
352
|
+
* guarantee for the ones it cannot see, i.e. copies with distinct uuids and
|
|
353
|
+
* rows collected from more than one file.
|
|
354
|
+
* • **A lower bound, not a cost.** `cache_read` grows LINEARLY with the
|
|
355
|
+
* subagent's tool-call count (13 → 37.6k, 19 → 59.5k, 26 → 76.5k), which is
|
|
356
|
+
* what one turn reading the whole accumulated context looks like — not a
|
|
357
|
+
* sum over turns, which would grow quadratically. So this is most likely
|
|
358
|
+
* the subagent's LAST turn. It rides on its own field and is never added to
|
|
359
|
+
* the row's cost.
|
|
360
|
+
* • **A result with no `agentId` is dropped, not guessed.** It cannot be
|
|
361
|
+
* deduplicated, and a double-counted lower bound is worse than a smaller
|
|
362
|
+
* one that is honest. Counted, so the omission is visible.
|
|
363
|
+
*/
|
|
364
|
+
function applyDelegation(p, entry, tsMs) {
|
|
365
|
+
const result = entry.toolUseResult;
|
|
366
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return;
|
|
367
|
+
const usage = result.usage;
|
|
368
|
+
if (!usage || typeof usage !== 'object') return;
|
|
369
|
+
|
|
370
|
+
const id = typeof result.agentId === 'string' ? result.agentId : '';
|
|
371
|
+
if (id === '') {
|
|
372
|
+
p.delegationsUnidentified++;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (p.delegations.has(id)) {
|
|
376
|
+
p.delegationDuplicates++;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const tokens = emptyTokens();
|
|
381
|
+
addUsage(tokens, usage);
|
|
382
|
+
const model = typeof result.resolvedModel === 'string' ? result.resolvedModel : '';
|
|
383
|
+
// Shaped as a one-model row so priceRow answers about it unchanged — including
|
|
384
|
+
// the refusals: an unknown model, an unsplit cache write or a tier we cannot
|
|
385
|
+
// price yields `list_cost_usd: null` here for exactly the same reasons as on a
|
|
386
|
+
// parent turn, and lands in the same unpriced counter downstream.
|
|
387
|
+
const cost = priceRow(
|
|
388
|
+
{
|
|
389
|
+
by_model: { [model]: tokens },
|
|
390
|
+
speed: typeof usage.speed === 'string' ? usage.speed : null,
|
|
391
|
+
service_tier: usage.service_tier,
|
|
392
|
+
inference_geo: usage.inference_geo,
|
|
393
|
+
},
|
|
394
|
+
{ at: tsMs },
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
p.delegations.set(id, {
|
|
398
|
+
agent_id: id,
|
|
399
|
+
agent_type: typeof result.agentType === 'string' ? result.agentType : '',
|
|
400
|
+
model,
|
|
401
|
+
status: typeof result.status === 'string' ? result.status : '',
|
|
402
|
+
duration_ms: Number.isFinite(result.totalDurationMs) ? result.totalDurationMs : null,
|
|
403
|
+
// The subagent's own tool calls — the quantity `cache_read` tracks linearly,
|
|
404
|
+
// i.e. the evidence for reading the figure above as a lower bound.
|
|
405
|
+
tool_calls_total: Number.isFinite(result.totalToolUseCount) ? result.totalToolUseCount : null,
|
|
406
|
+
...tokens,
|
|
407
|
+
web_search_requests: usage.server_tool_use?.web_search_requests || 0,
|
|
408
|
+
...cost,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Tools, delegations, model/effort, and the per-model token split. */
|
|
413
|
+
function applyAssistantEntry(p, entry, { usage, model, synthetic, repeatOfResponse }) {
|
|
414
|
+
for (const block of toolUseBlocks(entry)) {
|
|
415
|
+
const name = typeof block.name === 'string' ? block.name : '(unknown)';
|
|
416
|
+
p.tools.set(name, (p.tools.get(name) ?? 0) + 1);
|
|
417
|
+
p.toolCalls++;
|
|
418
|
+
if (AGENT_TOOLS.has(name)) p.agentSpawns++;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Counted, then dropped from every billing figure: Claude Code wrote it, the
|
|
422
|
+
// API did not bill it.
|
|
423
|
+
if (synthetic) {
|
|
424
|
+
p.syntheticTurns++;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// `usage` describes the RESPONSE, not the entry. One API response reaches the
|
|
429
|
+
// transcript as several entries — one per content block (thinking / text /
|
|
430
|
+
// each tool_use) — and every one of them carries an identical copy of the same
|
|
431
|
+
// usage block. The uuid dedup in addEntry() cannot see this class: those uuids
|
|
432
|
+
// differ by definition. Measured over 19 transcripts: 11 295 entries for 5 954
|
|
433
|
+
// responses = 1.90x on api_turns, and 2.02x on the dollar figure, because the
|
|
434
|
+
// per-class inflation is not flat (cache-read 1.82x, output 2.17x) — a
|
|
435
|
+
// many-block response is not a scaled copy of a one-block response.
|
|
436
|
+
//
|
|
437
|
+
// Not a constant that cancels in a ratio of two arms: the factor IS the number
|
|
438
|
+
// of content blocks per turn, i.e. how many tools the model reached for, which
|
|
439
|
+
// is the variable the doctrine sets out to move. Per-session spread on one
|
|
440
|
+
// project: 1.66x to 2.24x (error class 1 — a figure that prints like a fact).
|
|
441
|
+
//
|
|
442
|
+
// The tool_use loop above runs BEFORE this guard on purpose: tool_use blocks
|
|
443
|
+
// are NOT duplicated across a response's entries (6 403 blocks, 6 403 distinct
|
|
444
|
+
// ids, 0 repeats), so skipping the whole entry would drop ~2/3 of all tool
|
|
445
|
+
// calls and gut the adoption half of the analysis.
|
|
446
|
+
if (repeatOfResponse) return;
|
|
447
|
+
|
|
448
|
+
p.apiTurns++;
|
|
449
|
+
if (model) p.models.add(model);
|
|
450
|
+
if (typeof entry.effort === 'string') p.efforts.add(entry.effort);
|
|
451
|
+
if (!usage) return;
|
|
452
|
+
if (typeof usage.speed === 'string') p.speeds.add(usage.speed);
|
|
453
|
+
if (typeof usage.service_tier === 'string') p.tiers.add(usage.service_tier);
|
|
454
|
+
if (typeof usage.inference_geo === 'string') p.geos.add(usage.inference_geo);
|
|
455
|
+
// Per-model split, so a turn that switched models is still priceable exactly
|
|
456
|
+
// instead of being charged at one model's rate (error class 4). The row's
|
|
457
|
+
// flat totals are folded back out of this in toRow() — one source, no drift.
|
|
458
|
+
const key = model ?? '(unknown)';
|
|
459
|
+
if (!p.byModel.has(key)) p.byModel.set(key, { api_turns: 0, ...emptyTokens() });
|
|
460
|
+
const bucket = p.byModel.get(key);
|
|
461
|
+
bucket.api_turns++;
|
|
462
|
+
addUsage(bucket, usage);
|
|
463
|
+
|
|
464
|
+
if (Array.isArray(usage.iterations) && usage.iterations.length > 1) p.multiAttemptTurns++;
|
|
465
|
+
if (usageUnderstated(usage)) p.understatedTurns++;
|
|
466
|
+
// Billed per request, not per token, so they sit outside list_cost_by_class.
|
|
467
|
+
// Counted so the gap announces itself instead of hiding in a clean total.
|
|
468
|
+
const server = serverToolRequests(usage);
|
|
469
|
+
p.webSearchRequests += server.search;
|
|
470
|
+
p.webFetchRequests += server.fetch;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Streaming aggregator. One accumulator per turn, fed entry by entry in file
|
|
475
|
+
* order, so the caller never has to materialize a transcript (criterion 4).
|
|
476
|
+
*
|
|
477
|
+
* Attribution: `promptId` sits on `user` entries only — including the tool
|
|
478
|
+
* RESULTS, which come back as `user` entries, so it groups the whole turn.
|
|
479
|
+
* `assistant` entries carry no promptId and are resolved by walking `parentUuid`
|
|
480
|
+
* to the nearest ancestor that has one (2517/2517 in the measured session).
|
|
481
|
+
* The walk is O(1) per entry because every uuid seen is memoized with the group
|
|
482
|
+
* it resolved to, and parents always precede children in the file.
|
|
483
|
+
*
|
|
484
|
+
* Entries of ANY type are memoized, not just user/assistant: a `system` or
|
|
485
|
+
* `attachment` entry can sit in the middle of a chain, and skipping it would
|
|
486
|
+
* break the link for everything below it.
|
|
487
|
+
*
|
|
488
|
+
* An entry that resolves to nothing is counted into `unattributed` — with its
|
|
489
|
+
* tokens — and reported. Silently dropping it is the failure this file exists
|
|
490
|
+
* to avoid (criterion 2).
|
|
491
|
+
*/
|
|
492
|
+
export function createCollector() {
|
|
493
|
+
// Keyed by (session, promptId), never by promptId alone. Nothing documents
|
|
494
|
+
// promptId as globally unique — it is unique across the 29 local transcripts,
|
|
495
|
+
// which is a property of that sample, not of the format. If two sessions ever
|
|
496
|
+
// shared one, keying on it alone would merge their rows, and since the arm is
|
|
497
|
+
// a property of the SESSION (EPIC-009, error class 6) that merge would blend
|
|
498
|
+
// two arms into a single row and look like ordinary data.
|
|
499
|
+
const prompts = new Map(); // key -> accumulator, in first-seen order
|
|
500
|
+
const groups = new Map(); // key -> {key, promptId, session}, shared by memoized uuids
|
|
501
|
+
let uuidGroup = new Map(); // uuid -> group | null; reset per file (see beginFile)
|
|
502
|
+
const unattributed = {
|
|
503
|
+
entries: 0,
|
|
504
|
+
turns: 0,
|
|
505
|
+
// Counted for the same reason the rows count them: tool_calls is an
|
|
506
|
+
// ADOPTION metric — how often the model reached for a tool, i.e. the very
|
|
507
|
+
// variable the doctrine sets out to move — so an entry that lands outside
|
|
508
|
+
// every row must still show up in that denominator. `web_search_requests`
|
|
509
|
+
// is a different kind of thing: it is MONEY ($10/1000, billed per request),
|
|
510
|
+
// and money that counting the entry's tokens does not cover, because no
|
|
511
|
+
// token class prices it (TASK-093).
|
|
512
|
+
tool_calls: 0,
|
|
513
|
+
// A strict subset of tool_calls, tracked separately for the same reason the
|
|
514
|
+
// rows track it: agent_spawns is one half of how big the subagent blind spot
|
|
515
|
+
// is (error class 3), and a spawn nobody counted is a whole delegation whose
|
|
516
|
+
// cost we do not merely under-report — we never learn it happened.
|
|
517
|
+
agent_spawns: 0,
|
|
518
|
+
web_search_requests: 0,
|
|
519
|
+
web_fetch_requests: 0,
|
|
520
|
+
...emptyTokens(),
|
|
521
|
+
};
|
|
522
|
+
// Identity of every API response already billed. NOT reset in beginFile, and
|
|
523
|
+
// deliberately unlike uuidGroup: a uuid chain cannot cross a file, but nothing
|
|
524
|
+
// promises the same about a response id, and a global set costs one string per
|
|
525
|
+
// response. Measured across the 19 guidetrackee transcripts: 0 of 5 954 ids
|
|
526
|
+
// appear in more than one file, so today the scope changes no figure — it only
|
|
527
|
+
// removes a way for a future transcript layout to reopen the bug silently.
|
|
528
|
+
const seenResponses = new Set();
|
|
529
|
+
const totals = { malformed: 0, entries: 0, duplicates: 0, responseRepeats: 0, responsesUnidentified: 0, files: 0 };
|
|
530
|
+
|
|
531
|
+
function groupFor(entry) {
|
|
532
|
+
// Direct: `user` entries (prompt AND tool results) carry promptId.
|
|
533
|
+
const promptId = entry.promptId;
|
|
534
|
+
if (typeof promptId === 'string' && promptId !== '') {
|
|
535
|
+
const session = typeof entry.sessionId === 'string' ? entry.sessionId : null;
|
|
536
|
+
const key = `${session ?? ''}\u0000${promptId}`;
|
|
537
|
+
let group = groups.get(key);
|
|
538
|
+
if (!group) {
|
|
539
|
+
group = { key, promptId, session };
|
|
540
|
+
groups.set(key, group);
|
|
541
|
+
}
|
|
542
|
+
return group;
|
|
543
|
+
}
|
|
544
|
+
// Inherited: nearest ancestor with one. Ancestors are already memoized, and
|
|
545
|
+
// a memoized `null` correctly propagates "unattributable" down the chain.
|
|
546
|
+
const parent = entry.parentUuid;
|
|
547
|
+
return typeof parent === 'string' ? (uuidGroup.get(parent) ?? null) : null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function addEntry(entry) {
|
|
551
|
+
totals.entries++;
|
|
552
|
+
|
|
553
|
+
// A transcript repeats entries VERBATIM, and not rarely: 11 296 of 36 463
|
|
554
|
+
// local entries (31%) carry a uuid already written earlier IN THE SAME FILE,
|
|
555
|
+
// 6 739 of them assistant turns with a usage block. The copies are identical
|
|
556
|
+
// apart from a cosmetic `slug` — same tokens, same model, same timestamp —
|
|
557
|
+
// so summing both charged 45% of all cache-read tokens and 42% of all output
|
|
558
|
+
// tokens twice. That is not a rounding error, it is the epic's whole effect
|
|
559
|
+
// size several times over (error class 1: a figure that prints like a fact).
|
|
560
|
+
//
|
|
561
|
+
// Scoped to the file on purpose: across the 34 local transcripts ZERO uuids
|
|
562
|
+
// are shared between files, so the index that already exists per file
|
|
563
|
+
// (uuidGroup, dropped in beginFile) is exactly the right one to ask. First
|
|
564
|
+
// occurrence wins — file order is event order, and a later copy carrying a
|
|
565
|
+
// different promptId is the copy, not the event.
|
|
566
|
+
//
|
|
567
|
+
// Counted, never silent: a run over a transcript that suddenly stops
|
|
568
|
+
// repeating itself must show that too.
|
|
569
|
+
if (typeof entry.uuid === 'string' && uuidGroup.has(entry.uuid)) {
|
|
570
|
+
totals.duplicates++;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const group = groupFor(entry);
|
|
575
|
+
// Memoize BEFORE any type filtering: unknown types are links in the chain
|
|
576
|
+
// even when they carry no metric of their own.
|
|
577
|
+
if (typeof entry.uuid === 'string') uuidGroup.set(entry.uuid, group);
|
|
578
|
+
|
|
579
|
+
const type = entry.type;
|
|
580
|
+
const isAssistant = type === 'assistant';
|
|
581
|
+
// Everything else in a live transcript — queue-operation, ai-title,
|
|
582
|
+
// file-history-snapshot/delta, last-prompt, attachment, system, mode —
|
|
583
|
+
// carries no per-prompt metric. Skipped, never fatal.
|
|
584
|
+
if (type !== 'user' && !isAssistant) return;
|
|
585
|
+
|
|
586
|
+
const usage = entry.message?.usage;
|
|
587
|
+
const model = entry.message?.model ?? null;
|
|
588
|
+
const synthetic = isAssistant && model === SYNTHETIC_MODEL;
|
|
589
|
+
|
|
590
|
+
// Decided here, not inside applyAssistantEntry, because the unattributed
|
|
591
|
+
// branch below bills its own turns and tokens and never reaches that
|
|
592
|
+
// function — the same response split across blocks would inflate the one
|
|
593
|
+
// counter whose job is to report what fell outside the rows.
|
|
594
|
+
//
|
|
595
|
+
// `message.id` is the identity the API gives a response; `requestId` is the
|
|
596
|
+
// fallback for an entry that lacks it. Measured: the two are 1:1 (no id maps
|
|
597
|
+
// to two of the other, in either direction) and no entry has neither, so the
|
|
598
|
+
// fallback is belt-and-braces rather than load-bearing. An entry with NO id
|
|
599
|
+
// at all is counted rather than assumed unique — an unidentifiable response
|
|
600
|
+
// is not silently a new one — and reported via `responsesUnidentified`.
|
|
601
|
+
//
|
|
602
|
+
// Synthetic turns never enter the set: they are excluded from every billing
|
|
603
|
+
// figure anyway, and 8 of them carry a message.id (0 colliding with a real
|
|
604
|
+
// response) that would otherwise poison it.
|
|
605
|
+
let repeatOfResponse = false;
|
|
606
|
+
if (isAssistant && !synthetic) {
|
|
607
|
+
// Non-empty strings only, the same test groupFor() applies to promptId.
|
|
608
|
+
// `??` alone would accept `id: ''`, and an empty id shared by every entry
|
|
609
|
+
// in a file would collapse the whole file into ONE response — the bill
|
|
610
|
+
// would fall to near zero and read as a clean result. Under-counting is
|
|
611
|
+
// the worse direction: an over-count announces itself as implausible.
|
|
612
|
+
const id = entry.message?.id;
|
|
613
|
+
const req = entry.requestId;
|
|
614
|
+
const responseId =
|
|
615
|
+
(typeof id === 'string' && id !== '' && id) || (typeof req === 'string' && req !== '' && req) || null;
|
|
616
|
+
if (responseId === null) totals.responsesUnidentified++;
|
|
617
|
+
else if (seenResponses.has(responseId)) {
|
|
618
|
+
repeatOfResponse = true;
|
|
619
|
+
totals.responseRepeats++;
|
|
620
|
+
} else seenResponses.add(responseId);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (group === null) {
|
|
624
|
+
unattributed.entries++;
|
|
625
|
+
// Per ENTRY, above the repeat guard and outside the synthetic test —
|
|
626
|
+
// exactly where the row path counts them, and for its reasons: tool_use
|
|
627
|
+
// blocks are NOT duplicated across the entries of one response
|
|
628
|
+
// (TASK-092), and a synthetic turn's tool call still happened even though
|
|
629
|
+
// nobody billed for it.
|
|
630
|
+
if (isAssistant) {
|
|
631
|
+
for (const block of toolUseBlocks(entry)) {
|
|
632
|
+
unattributed.tool_calls++;
|
|
633
|
+
if (AGENT_TOOLS.has(block.name)) unattributed.agent_spawns++;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (isAssistant && !synthetic && !repeatOfResponse) {
|
|
637
|
+
unattributed.turns++;
|
|
638
|
+
if (usage) {
|
|
639
|
+
addUsage(unattributed, usage);
|
|
640
|
+
// Under the guard, the mirror image of the tool calls above:
|
|
641
|
+
// server_tool_use is part of `usage`, so it IS copied onto every entry
|
|
642
|
+
// of a split response, and counting it per entry would re-open
|
|
643
|
+
// TASK-092 in the one place we just closed it.
|
|
644
|
+
const server = serverToolRequests(usage);
|
|
645
|
+
unattributed.web_search_requests += server.search;
|
|
646
|
+
unattributed.web_fetch_requests += server.fetch;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Created here, not in groupFor(): an entry of an unknown type must never
|
|
653
|
+
// conjure a row of zeros just by carrying a promptId.
|
|
654
|
+
let p = prompts.get(group.key);
|
|
655
|
+
if (!p) {
|
|
656
|
+
p = newPrompt(group);
|
|
657
|
+
prompts.set(group.key, p);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
applyIdentity(p, entry);
|
|
661
|
+
applyTiming(p, entry, isAssistant);
|
|
662
|
+
// Guarded like api_turns, and for the same reason: this is a TURN counter —
|
|
663
|
+
// it is the figure that says how big the subagent blind spot is (error class
|
|
664
|
+
// 3), paired with agent_spawns. A subagent response splits across content
|
|
665
|
+
// blocks like any other, and being tool-heavy it splits MORE, so counting it
|
|
666
|
+
// per entry would inflate the very number that measures what we cannot see.
|
|
667
|
+
// Zero isSidechain entries in all data measured so far, so this moves no
|
|
668
|
+
// current figure — it stops the bug from arriving with the first one.
|
|
669
|
+
if (entry.isSidechain === true && isAssistant && !repeatOfResponse) p.sidechainTurns++;
|
|
670
|
+
if (isAssistant) applyAssistantEntry(p, entry, { usage, model, synthetic, repeatOfResponse });
|
|
671
|
+
else {
|
|
672
|
+
applyUserEntry(p, entry);
|
|
673
|
+
// Tool results come back as `user` entries; the delegation's bill rides on
|
|
674
|
+
// the one belonging to an `Agent` call, priced at the moment it happened.
|
|
675
|
+
applyDelegation(p, entry, msOf(entry));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function addLine(line) {
|
|
680
|
+
const entry = parseEntry(line);
|
|
681
|
+
// A truncated or corrupted line costs exactly that line (criterion 3): a
|
|
682
|
+
// transcript is appended to live, so the last line can be half-written at
|
|
683
|
+
// any moment. Counted, so a run over garbage doesn't look like a clean run.
|
|
684
|
+
if (entry === null) {
|
|
685
|
+
if (typeof line === 'string' && line.trim() !== '') totals.malformed++;
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
addEntry(entry);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function toRow(p) {
|
|
692
|
+
const queued_s = seconds(p.tsSubmit, p.tsFirstAssistant);
|
|
693
|
+
const work_s = seconds(p.tsFirstAssistant, p.tsEnd);
|
|
694
|
+
// Flat totals are the fold of the per-model split, not a second running
|
|
695
|
+
// sum: EPIC-009 criterion 2 forbids printing a token figure that disagrees
|
|
696
|
+
// with its own breakdown, and the cheapest way to guarantee that is to have
|
|
697
|
+
// only one number to disagree with.
|
|
698
|
+
const tokens = emptyTokens();
|
|
699
|
+
for (const bucket of p.byModel.values()) {
|
|
700
|
+
for (const field of Object.keys(tokens)) tokens[field] += bucket[field];
|
|
701
|
+
}
|
|
702
|
+
const row = {
|
|
703
|
+
promptId: p.promptId,
|
|
704
|
+
session: p.session,
|
|
705
|
+
cwd: p.cwd,
|
|
706
|
+
// Always written, empty string included: an absent arm marker is data
|
|
707
|
+
// (TASK-088), and a row that omits the field would be indistinguishable
|
|
708
|
+
// from a row the parser failed on.
|
|
709
|
+
gitBranch: p.gitBranch ?? '',
|
|
710
|
+
gitBranchChanged: p.gitBranchChanged,
|
|
711
|
+
cc_version: p.ccVersion,
|
|
712
|
+
ts_submit: isoOrNull(p.tsSubmit),
|
|
713
|
+
ts_first_assistant: isoOrNull(p.tsFirstAssistant),
|
|
714
|
+
ts_end: isoOrNull(p.tsEnd),
|
|
715
|
+
// Split deliberately. A single duration measured from submission swallows
|
|
716
|
+
// queue wait — 84 `enqueue` events in one measured session — and that is
|
|
717
|
+
// the user's time, not the tool's. TASK-089 compares work_s.
|
|
718
|
+
queued_s,
|
|
719
|
+
work_s,
|
|
720
|
+
max_gap_s: p.maxGap / 1000,
|
|
721
|
+
model: collapse(p.models),
|
|
722
|
+
models: [...p.models].sort(),
|
|
723
|
+
effort: collapse(p.efforts),
|
|
724
|
+
efforts: [...p.efforts].sort(),
|
|
725
|
+
speed: collapse(p.speeds),
|
|
726
|
+
service_tier: collapse(p.tiers),
|
|
727
|
+
inference_geo: collapse(p.geos),
|
|
728
|
+
api_turns: p.apiTurns,
|
|
729
|
+
synthetic_turns: p.syntheticTurns,
|
|
730
|
+
...tokens,
|
|
731
|
+
by_model: Object.fromEntries([...p.byModel.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
|
|
732
|
+
tools: Object.fromEntries([...p.tools.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))),
|
|
733
|
+
tool_calls_total: p.toolCalls,
|
|
734
|
+
agent_spawns: p.agentSpawns,
|
|
735
|
+
sidechain_turns: p.sidechainTurns,
|
|
736
|
+
// Deliberately NOT folded into the row's tokens or cost: what the
|
|
737
|
+
// subagents cost is a lower bound (see applyDelegation), and a lower bound
|
|
738
|
+
// added into a total stops being labelled as one. Sorted so two runs over
|
|
739
|
+
// the same transcripts produce byte-identical rows.
|
|
740
|
+
delegations: [...p.delegations.values()].sort((a, b) => a.agent_id.localeCompare(b.agent_id)),
|
|
741
|
+
delegation_duplicates_dropped: p.delegationDuplicates,
|
|
742
|
+
delegations_unidentified: p.delegationsUnidentified,
|
|
743
|
+
multi_attempt_turns: p.multiAttemptTurns,
|
|
744
|
+
understated_turns: p.understatedTurns,
|
|
745
|
+
web_search_requests: p.webSearchRequests,
|
|
746
|
+
web_fetch_requests: p.webFetchRequests,
|
|
747
|
+
prompt_chars: p.promptChars ?? 0,
|
|
748
|
+
};
|
|
749
|
+
// Priced from the row that was just built, so the dollars can only ever be
|
|
750
|
+
// a function of the published components — nothing is billed that the row
|
|
751
|
+
// does not also show (EPIC-009 criterion 2). Priced at the prompt's own
|
|
752
|
+
// timestamp, so re-running this next year yields the same money.
|
|
753
|
+
return { ...row, ...priceRow(row) };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
addLine,
|
|
758
|
+
addEntry,
|
|
759
|
+
/**
|
|
760
|
+
* Start of a new transcript. Drops the uuid index, because a parentUuid
|
|
761
|
+
* chain never crosses a file — so keeping it would only let one file's uuid
|
|
762
|
+
* capture another file's entry, and would grow the index across a whole
|
|
763
|
+
* scan (17 403 uuids over the 29 local transcripts) for no gain. The
|
|
764
|
+
* per-turn accumulators survive: rows accumulate across files by design.
|
|
765
|
+
*/
|
|
766
|
+
beginFile() {
|
|
767
|
+
totals.files++;
|
|
768
|
+
uuidGroup = new Map();
|
|
769
|
+
},
|
|
770
|
+
result() {
|
|
771
|
+
const rows = [...prompts.values()].map(toRow);
|
|
772
|
+
const sum = (field) => rows.reduce((n, r) => n + r[field], 0);
|
|
773
|
+
const tokens = emptyTokens();
|
|
774
|
+
for (const row of rows) for (const field of Object.keys(tokens)) tokens[field] += row[field];
|
|
775
|
+
return {
|
|
776
|
+
rows,
|
|
777
|
+
// Reported on every run, never inferred from `rows` being short.
|
|
778
|
+
unattributed,
|
|
779
|
+
tokens,
|
|
780
|
+
cost: summarizeCost(rows),
|
|
781
|
+
// Rolled up with the SAME summarizer as the rows — a delegation record
|
|
782
|
+
// carries the cost fields summarizeCost reads, so the lower bound is
|
|
783
|
+
// built by the code that already knows how to refuse an unpriced row.
|
|
784
|
+
// Kept beside `cost`, never inside it (TASK-089 criterion 8b).
|
|
785
|
+
delegationCost: summarizeCost(rows.flatMap((r) => r.delegations)),
|
|
786
|
+
totals: {
|
|
787
|
+
...totals,
|
|
788
|
+
prompts: rows.length,
|
|
789
|
+
agent_spawns: sum('agent_spawns'),
|
|
790
|
+
delegations_measured: rows.reduce((n, r) => n + r.delegations.length, 0),
|
|
791
|
+
delegation_duplicates_dropped: sum('delegation_duplicates_dropped'),
|
|
792
|
+
delegations_unidentified: sum('delegations_unidentified'),
|
|
793
|
+
sidechain_turns: sum('sidechain_turns'),
|
|
794
|
+
synthetic_turns: sum('synthetic_turns'),
|
|
795
|
+
multi_attempt_turns: sum('multi_attempt_turns'),
|
|
796
|
+
understated_turns: sum('understated_turns'),
|
|
797
|
+
web_search_requests: sum('web_search_requests'),
|
|
798
|
+
web_fetch_requests: sum('web_fetch_requests'),
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Convenience wrapper for tests and small inputs: entries in, result out. */
|
|
806
|
+
export function aggregate(entries) {
|
|
807
|
+
const c = createCollector();
|
|
808
|
+
for (const entry of entries) c.addEntry(entry);
|
|
809
|
+
return c.result();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function formatRow(row) {
|
|
813
|
+
return JSON.stringify(row);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Human-readable run summary. Goes to stderr so stdout stays parseable JSONL.
|
|
818
|
+
* Names the two things that are NOT in the numbers — unattributed entries and
|
|
819
|
+
* the uncaptured cost of delegation — because a summary that only reports what
|
|
820
|
+
* it measured is how a blind spot becomes a clean-looking result.
|
|
821
|
+
*/
|
|
822
|
+
export function formatSummary({ totals, unattributed, cost, tokens, delegationCost }) {
|
|
823
|
+
const L = [];
|
|
824
|
+
L.push(
|
|
825
|
+
`prompt-metrics: ${totals.prompts} prompt(s) from ${totals.entries} entries` +
|
|
826
|
+
` in ${totals.files} transcript(s)` +
|
|
827
|
+
(totals.malformed > 0 ? `; ${totals.malformed} malformed line(s) skipped` : ''),
|
|
828
|
+
);
|
|
829
|
+
if (totals.duplicates > 0) {
|
|
830
|
+
// Loud, because the figure below is ~1.8x smaller than it used to be and a
|
|
831
|
+
// reader comparing two runs of this tool deserves to know why.
|
|
832
|
+
L.push(
|
|
833
|
+
` repeated entries skipped: ${totals.duplicates} (same uuid, already seen in this file)` +
|
|
834
|
+
' — counting them charged the same tokens twice',
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
if (totals.responseRepeats > 0) {
|
|
838
|
+
// Its own line, never folded into the uuid count above: the two are
|
|
839
|
+
// different defects with different fixes, and this one halves the bill. A
|
|
840
|
+
// reader comparing this run against a pre-TASK-092 run must be able to see
|
|
841
|
+
// which of the two moved the number.
|
|
842
|
+
L.push(
|
|
843
|
+
` extra entries of the same response: ${totals.responseRepeats}` +
|
|
844
|
+
' (same message.id — one API response split across content blocks; usage counted once)',
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
if (totals.responsesUnidentified > 0) {
|
|
848
|
+
// Counted, not assumed unique: an entry with neither id could be a fresh
|
|
849
|
+
// response or the fourth copy of one already billed. We charge it (an
|
|
850
|
+
// unknown response is never priced at zero) and say so.
|
|
851
|
+
L.push(
|
|
852
|
+
` assistant entries with no message.id and no requestId: ${totals.responsesUnidentified}` +
|
|
853
|
+
' — billed as distinct responses; may double-count if they are split blocks',
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
L.push(
|
|
857
|
+
` unattributed entries: ${unattributed.entries}` +
|
|
858
|
+
` (${unattributed.turns} API turn(s), ${unattributed.output_tokens} output tokens) — not in any row`,
|
|
859
|
+
);
|
|
860
|
+
// Its own line, and only the parts that are non-zero — a clean run stays
|
|
861
|
+
// clean, and so does a run whose only gap is one of the three. Split from the
|
|
862
|
+
// line above because the halves are different kinds of gap: the tool calls are
|
|
863
|
+
// missing from an adoption DENOMINATOR, the searches are missing DOLLARS, and
|
|
864
|
+
// dollars that appear in no cost figure this tool prints, here or below.
|
|
865
|
+
const outside = [];
|
|
866
|
+
if (unattributed.tool_calls > 0) {
|
|
867
|
+
outside.push(
|
|
868
|
+
`${unattributed.tool_calls} tool call(s)` +
|
|
869
|
+
(unattributed.agent_spawns > 0
|
|
870
|
+
? `, ${unattributed.agent_spawns} of them subagent spawn(s) whose cost is UNCAPTURED (class 3)`
|
|
871
|
+
: ''),
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
if (unattributed.web_search_requests > 0) {
|
|
875
|
+
outside.push(
|
|
876
|
+
`${unattributed.web_search_requests} web search(es) =` +
|
|
877
|
+
` ${formatUsd(webSearchCost(unattributed.web_search_requests))} NOT in any cost figure below`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (unattributed.web_fetch_requests > 0) outside.push(`${unattributed.web_fetch_requests} web fetch(es) (free)`);
|
|
881
|
+
if (outside.length > 0) L.push(` also outside every row: ${outside.join(', ')}`);
|
|
882
|
+
L.push(` subagent spawns: ${totals.agent_spawns}; sidechain turns in transcript: ${totals.sidechain_turns}`);
|
|
883
|
+
if (totals.delegations_measured > 0) {
|
|
884
|
+
// Two numbers, never one: what we could price, and how far the spawns
|
|
885
|
+
// outrun it. A spawn with no result entry is cost we have NO figure for.
|
|
886
|
+
const missing = Math.max(0, totals.agent_spawns - totals.delegations_measured);
|
|
887
|
+
L.push(
|
|
888
|
+
` delegations priced from their own usage: ${totals.delegations_measured}` +
|
|
889
|
+
` (${totals.delegation_duplicates_dropped} duplicate entr(ies) dropped` +
|
|
890
|
+
`, ${totals.delegations_unidentified} without an agentId, not counted)`,
|
|
891
|
+
);
|
|
892
|
+
if (delegationCost) {
|
|
893
|
+
L.push(
|
|
894
|
+
` subagent LOWER BOUND: ${formatUsd(delegationCost.list_cost_usd)} over` +
|
|
895
|
+
` ${delegationCost.priced_rows} of ${delegationCost.total_rows} delegation(s)` +
|
|
896
|
+
' — separate from the figure above, NEVER added to it (procedure §11a)',
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
if (missing > 0) {
|
|
900
|
+
L.push(` ${missing} spawn(s) left no priced result — their cost is UNCAPTURED, not zero (EPIC-009 class 3)`);
|
|
901
|
+
}
|
|
902
|
+
} else if (totals.agent_spawns > 0) {
|
|
903
|
+
L.push(' subagent cost is UNCAPTURED, not zero (EPIC-009 class 3)');
|
|
904
|
+
}
|
|
905
|
+
if (totals.synthetic_turns > 0) {
|
|
906
|
+
L.push(` <synthetic> turns excluded from tokens/turns: ${totals.synthetic_turns}`);
|
|
907
|
+
}
|
|
908
|
+
if (cost) L.push(...formatCostLines(cost, tokens));
|
|
909
|
+
if (totals.understated_turns > 0 || totals.multi_attempt_turns > 0) {
|
|
910
|
+
L.push(
|
|
911
|
+
` turns where usage.iterations exceeds the reported usage: ${totals.understated_turns}` +
|
|
912
|
+
` (${totals.multi_attempt_turns} with >1 attempt) — cost above is a known UNDERestimate`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
return L.join('\n');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Lines of a file, without ever holding more than a buffer of it. Transcripts
|
|
920
|
+
* reach tens of MB (16.6MB locally), and a tool whose own footprint grows with
|
|
921
|
+
* session length is the cost curve this project exists to fight — the same rule
|
|
922
|
+
* the context-reminder hook follows with readTail(). That hook needs only the
|
|
923
|
+
* tail; here every line matters, so it is a line stream rather than a slice.
|
|
924
|
+
*/
|
|
925
|
+
export async function* readLines(path) {
|
|
926
|
+
const rl = createInterface({ input: createReadStream(path, 'utf8'), crlfDelay: Infinity });
|
|
927
|
+
try {
|
|
928
|
+
for await (const line of rl) yield line;
|
|
929
|
+
} finally {
|
|
930
|
+
rl.close();
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export async function collectFiles(paths, collector = createCollector()) {
|
|
935
|
+
for (const path of paths) {
|
|
936
|
+
collector.beginFile();
|
|
937
|
+
for await (const line of readLines(path)) collector.addLine(line);
|
|
938
|
+
}
|
|
939
|
+
return collector;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Transcripts directly in `dir` or in its immediate subdirectories, sorted. */
|
|
943
|
+
export function findTranscripts(dir) {
|
|
944
|
+
if (!existsSync(dir)) return [];
|
|
945
|
+
const out = [];
|
|
946
|
+
const visit = (base, depth) => {
|
|
947
|
+
let names;
|
|
948
|
+
try {
|
|
949
|
+
names = readdirSync(base);
|
|
950
|
+
} catch {
|
|
951
|
+
return; // unreadable dir — skip, never fatal
|
|
952
|
+
}
|
|
953
|
+
for (const name of names.sort()) {
|
|
954
|
+
const full = join(base, name);
|
|
955
|
+
let st;
|
|
956
|
+
try {
|
|
957
|
+
st = statSync(full);
|
|
958
|
+
} catch {
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (st.isDirectory()) {
|
|
962
|
+
if (depth > 0) visit(full, depth - 1);
|
|
963
|
+
} else if (name.endsWith('.jsonl')) {
|
|
964
|
+
out.push(full);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
visit(dir, 1);
|
|
969
|
+
return out;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function defaultDir() {
|
|
973
|
+
return join(claudeBaseDir(), 'projects');
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function main() {
|
|
977
|
+
let paths;
|
|
978
|
+
try {
|
|
979
|
+
const { values } = parseArgs({
|
|
980
|
+
args: process.argv.slice(2),
|
|
981
|
+
options: {
|
|
982
|
+
transcript: { type: 'string', multiple: true },
|
|
983
|
+
dir: { type: 'string' },
|
|
984
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
985
|
+
},
|
|
986
|
+
strict: true,
|
|
987
|
+
});
|
|
988
|
+
if (values.help) {
|
|
989
|
+
process.stdout.write(`${USAGE}\n`);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
// Both flags together are a UNION, deduplicated, not a precedence rule:
|
|
993
|
+
// silently ignoring one of two flags the user passed is how a run measures
|
|
994
|
+
// something other than what was asked for.
|
|
995
|
+
const explicit = values.transcript ?? [];
|
|
996
|
+
const scanned = values.dir !== undefined || explicit.length === 0 ? findTranscripts(values.dir ?? defaultDir()) : [];
|
|
997
|
+
paths = [...new Set([...explicit, ...scanned])];
|
|
998
|
+
const missing = paths.filter((p) => !existsSync(p));
|
|
999
|
+
if (missing.length > 0) {
|
|
1000
|
+
// Same shape as adoption-report's missing-log case: say where it looked,
|
|
1001
|
+
// change nothing else, exit 0. Note goes to stderr because stdout here is
|
|
1002
|
+
// a data channel, not a report.
|
|
1003
|
+
for (const p of missing) process.stderr.write(`prompt-metrics: no transcript at ${p}\n`);
|
|
1004
|
+
paths = paths.filter((p) => existsSync(p));
|
|
1005
|
+
}
|
|
1006
|
+
if (paths.length === 0) {
|
|
1007
|
+
process.stderr.write(
|
|
1008
|
+
`prompt-metrics: no transcripts found in ${values.dir ?? defaultDir()}.\n` +
|
|
1009
|
+
'(Claude Code writes them to <config dir>/projects/<slug>/<session>.jsonl.)\n',
|
|
1010
|
+
);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
} catch (e) {
|
|
1014
|
+
process.stderr.write(`prompt-metrics: ${e.message}\n`);
|
|
1015
|
+
process.exitCode = 1;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Piping into `head` closes stdout early; that is a normal end, not a crash.
|
|
1020
|
+
process.stdout.on('error', (e) => {
|
|
1021
|
+
if (e.code !== 'EPIPE') throw e;
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const collector = await collectFiles(paths);
|
|
1025
|
+
const result = collector.result();
|
|
1026
|
+
|
|
1027
|
+
// The arm is added here, not in the collector: the collector turns a
|
|
1028
|
+
// transcript into rows and has no business knowing about the experiment, and
|
|
1029
|
+
// the ledger is machine state rather than transcript content. The split also
|
|
1030
|
+
// lets the same rules run over rows collected elsewhere (TASK-088).
|
|
1031
|
+
const ledgerPath = experimentPaths(claudeBaseDir()).ledgerPath;
|
|
1032
|
+
const ledgerAvailable = existsSync(ledgerPath);
|
|
1033
|
+
const rows = annotateRows(result.rows, {
|
|
1034
|
+
ledger: ledgerAvailable ? readLedger(claudeBaseDir()) : [],
|
|
1035
|
+
ledgerAvailable,
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
for (const row of rows) process.stdout.write(`${formatRow(row)}\n`);
|
|
1039
|
+
process.stderr.write(`${formatSummary(result)}\n`);
|
|
1040
|
+
const arms = formatArmSummary(rows);
|
|
1041
|
+
if (arms !== null) process.stderr.write(`${arms}\n`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (process.argv[1]?.endsWith('prompt-metrics.mjs')) {
|
|
1045
|
+
await main();
|
|
1046
|
+
}
|