@bglocation/tune-context 2.3.1 → 2.4.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 +78 -0
- package/README.md +111 -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 +8 -6
- package/eval/arm.mjs +242 -0
- package/eval/pricing.mjs +430 -0
- package/eval/prompt-metrics.mjs +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,718 @@
|
|
|
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. Their
|
|
24
|
+
// cost is UNCAPTURED, not zero. `agent_spawns` is the only trace of it in
|
|
25
|
+
// the data, so it is always counted; `sidechain_turns` is counted too, so
|
|
26
|
+
// the blind spot closes by itself if a future Claude Code starts writing
|
|
27
|
+
// those entries (EPIC-009, error class 3).
|
|
28
|
+
// • `<synthetic>` assistant entries are generated by Claude Code, not billed
|
|
29
|
+
// by the API. They are excluded from tokens/turns and counted separately —
|
|
30
|
+
// an unbillable entry, not TASK-086's "unknown model".
|
|
31
|
+
//
|
|
32
|
+
// The `usage` reading here deliberately does NOT share code with
|
|
33
|
+
// hooks/context-reminder.mjs, which also parses it. That hook answers "how big
|
|
34
|
+
// is the context right now" and sums the three input classes into one number;
|
|
35
|
+
// this file must keep them apart, because they are priced at 1x / 0.1x /
|
|
36
|
+
// 1.25x-2x. One helper serving both would have to pick a side. (A hook also
|
|
37
|
+
// cannot import from eval/ — only hooks/ is installed.)
|
|
38
|
+
import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
|
|
39
|
+
import { createInterface } from 'node:readline';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import { parseArgs } from 'node:util';
|
|
42
|
+
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
43
|
+
import { experimentPaths, readLedger } from '../cli/experiment.mjs';
|
|
44
|
+
import { annotateRows, formatArmSummary } from './arm.mjs';
|
|
45
|
+
import { formatCostLines, priceRow, summarizeCost } from './pricing.mjs';
|
|
46
|
+
|
|
47
|
+
// Tool names that spawn a subagent. `Agent` is what this Claude Code writes
|
|
48
|
+
// (measured); `Task` is the name the same tool carried in earlier versions.
|
|
49
|
+
// Both are counted so a version change cannot silently zero the delegation
|
|
50
|
+
// count — the one number that lets TASK-089 say "uncaptured" instead of nothing.
|
|
51
|
+
const AGENT_TOOLS = new Set(['Agent', 'Task']);
|
|
52
|
+
|
|
53
|
+
// Model string Claude Code puts on entries it generated itself (interrupts,
|
|
54
|
+
// local notices). Never an API call, never a cost.
|
|
55
|
+
const SYNTHETIC_MODEL = '<synthetic>';
|
|
56
|
+
|
|
57
|
+
const USAGE = `
|
|
58
|
+
prompt-metrics — per-prompt time/token/tool metrics from Claude Code transcripts.
|
|
59
|
+
|
|
60
|
+
Usage:
|
|
61
|
+
prompt-metrics [--transcript <path>]... [--dir <path>]
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--transcript <path> A single .jsonl transcript. Repeatable.
|
|
65
|
+
--dir <path> Directory of transcripts, scanned one level deep. Used
|
|
66
|
+
together with --transcript, the two are unioned.
|
|
67
|
+
(default when neither is given: <config dir>/projects,
|
|
68
|
+
where <config dir> is $CLAUDE_CONFIG_DIR if set,
|
|
69
|
+
else ~/.claude)
|
|
70
|
+
-h, --help Show this help
|
|
71
|
+
|
|
72
|
+
Writes JSONL to stdout, one row per prompt. A run summary — including how many
|
|
73
|
+
entries could NOT be attributed to a prompt, and how many rows carry a model the
|
|
74
|
+
price table does not know — goes to stderr, so stdout stays a clean data channel.
|
|
75
|
+
|
|
76
|
+
Rows from a branch shaped exp/<task>/<arm> (the paired measurement, TASK-088)
|
|
77
|
+
also carry:
|
|
78
|
+
exp_task, exp_arm the arm, resolved — '' when it could not be resolved
|
|
79
|
+
exp_arm_source branch+ledger | branch | none | conflict:<why>
|
|
80
|
+
exp_ledger_arm what the TASK-087 ledger said, whenever it resolved —
|
|
81
|
+
on agreement too, so a confirmation can be audited
|
|
82
|
+
pass_order 1 for the arm run first in that task, 2 for the second
|
|
83
|
+
The branch says which arm was INTENDED, the ledger which was SET; a disagreement
|
|
84
|
+
leaves the arm unresolved rather than picking a winner.
|
|
85
|
+
|
|
86
|
+
These five fields are added here, in the CLI. Rows obtained through the module
|
|
87
|
+
(aggregate, collectFiles) carry the measurement only — call annotateRows from
|
|
88
|
+
eval/arm.mjs to add the arm.
|
|
89
|
+
`.trim();
|
|
90
|
+
|
|
91
|
+
/** Parse one transcript line. Returns null for blanks, junk, or non-objects. */
|
|
92
|
+
export function parseEntry(line) {
|
|
93
|
+
const t = typeof line === 'string' ? line.trim() : '';
|
|
94
|
+
if (t === '') return null;
|
|
95
|
+
try {
|
|
96
|
+
const o = JSON.parse(t);
|
|
97
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every entry of a transcript, malformed lines dropped. Pure over text — the
|
|
105
|
+
* I/O-free seam tests use. Real runs go through readLines()/collectFiles()
|
|
106
|
+
* instead, which never hold the whole file (see readLines).
|
|
107
|
+
*/
|
|
108
|
+
export function parseEntries(text) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const line of text.split('\n')) {
|
|
111
|
+
const e = parseEntry(line);
|
|
112
|
+
if (e !== null) out.push(e);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function msOf(entry) {
|
|
118
|
+
const t = Date.parse(entry?.timestamp);
|
|
119
|
+
return Number.isNaN(t) ? null : t;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isoOrNull(ms) {
|
|
123
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Elapsed seconds, 1ms precision; null when either end is unknown. */
|
|
127
|
+
function seconds(fromMs, toMs) {
|
|
128
|
+
if (fromMs === null || toMs === null) return null;
|
|
129
|
+
return Math.round(toMs - fromMs) / 1000;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A prompt may span several models/efforts/tiers (measured: one session carried
|
|
133
|
+
// claude-opus-5, claude-sonnet-5 and <synthetic>; effort took high/xhigh/max).
|
|
134
|
+
// The row keeps a single scalar for the common case and the literal string
|
|
135
|
+
// 'mixed' otherwise — never an average, never a silent first-wins. The full
|
|
136
|
+
// distinct set rides alongside (`models`, `efforts`) so 'mixed' is decodable
|
|
137
|
+
// and by_model below still prices the row exactly.
|
|
138
|
+
function collapse(set) {
|
|
139
|
+
if (set.size === 0) return null;
|
|
140
|
+
if (set.size === 1) return [...set][0];
|
|
141
|
+
return 'mixed';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function emptyTokens() {
|
|
145
|
+
return {
|
|
146
|
+
input_tokens: 0,
|
|
147
|
+
cache_read: 0,
|
|
148
|
+
cache_write_5m: 0,
|
|
149
|
+
cache_write_1h: 0,
|
|
150
|
+
// Cache-creation tokens the entry did NOT break down by TTL. Kept separate
|
|
151
|
+
// rather than folded into 5m: the write multipliers differ (1.25× vs 2×),
|
|
152
|
+
// so guessing a TTL here would quietly invent up to 60% of a cost line.
|
|
153
|
+
// Zero on every entry measured so far; if it ever isn't, TASK-086 sees it.
|
|
154
|
+
cache_write_unsplit: 0,
|
|
155
|
+
output_tokens: 0,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function addUsage(into, usage) {
|
|
160
|
+
const created = usage.cache_creation_input_tokens || 0;
|
|
161
|
+
const m5 = usage.cache_creation?.ephemeral_5m_input_tokens || 0;
|
|
162
|
+
const h1 = usage.cache_creation?.ephemeral_1h_input_tokens || 0;
|
|
163
|
+
into.input_tokens += usage.input_tokens || 0;
|
|
164
|
+
into.cache_read += usage.cache_read_input_tokens || 0;
|
|
165
|
+
into.cache_write_5m += m5;
|
|
166
|
+
into.cache_write_1h += h1;
|
|
167
|
+
into.cache_write_unsplit += Math.max(0, created - m5 - h1);
|
|
168
|
+
into.output_tokens += usage.output_tokens || 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function newPrompt({ promptId, session }) {
|
|
172
|
+
return {
|
|
173
|
+
promptId,
|
|
174
|
+
session,
|
|
175
|
+
cwd: null,
|
|
176
|
+
gitBranch: null,
|
|
177
|
+
gitBranchChanged: false,
|
|
178
|
+
ccVersion: null,
|
|
179
|
+
tsSubmit: null,
|
|
180
|
+
tsFirstAssistant: null,
|
|
181
|
+
tsEnd: null,
|
|
182
|
+
lastTs: null,
|
|
183
|
+
maxGap: 0,
|
|
184
|
+
models: new Set(),
|
|
185
|
+
efforts: new Set(),
|
|
186
|
+
speeds: new Set(),
|
|
187
|
+
tiers: new Set(),
|
|
188
|
+
// Data residency: `us` costs 1.1x across every token class (Claude 4.6+).
|
|
189
|
+
geos: new Set(),
|
|
190
|
+
apiTurns: 0,
|
|
191
|
+
syntheticTurns: 0,
|
|
192
|
+
// No flat token totals here on purpose: the row's input_tokens/cache_*/
|
|
193
|
+
// output_tokens are DERIVED from byModel in toRow(). Two accumulators fed
|
|
194
|
+
// from the same usage block are two things that can drift, and the drift
|
|
195
|
+
// would show up as a cost that does not match its own breakdown.
|
|
196
|
+
byModel: new Map(),
|
|
197
|
+
// Turns whose `usage.iterations` says more was spent than the top-level
|
|
198
|
+
// `usage` reports — see usageUnderstated(). Known underestimation, counted
|
|
199
|
+
// rather than corrected (TASK-086).
|
|
200
|
+
multiAttemptTurns: 0,
|
|
201
|
+
understatedTurns: 0,
|
|
202
|
+
// Split because they are billed differently: web search is $10/1000, web
|
|
203
|
+
// fetch is free. Counting them together priced free requests.
|
|
204
|
+
webSearchRequests: 0,
|
|
205
|
+
webFetchRequests: 0,
|
|
206
|
+
tools: new Map(),
|
|
207
|
+
toolCalls: 0,
|
|
208
|
+
agentSpawns: 0,
|
|
209
|
+
sidechainTurns: 0,
|
|
210
|
+
promptChars: null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Sum over whatever classes emptyTokens() defines — a new class cannot slip
|
|
215
|
+
* out of this total by being forgotten in a hand-written list. */
|
|
216
|
+
function totalTokens(t) {
|
|
217
|
+
return Object.values(t).reduce((a, b) => a + b, 0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `usage.iterations` is a per-attempt breakdown that appears when a request was
|
|
222
|
+
* retried or fell back to another model. It is NOT summed with the top-level
|
|
223
|
+
* `usage`, which describes only the attempt that answered — adding both counts
|
|
224
|
+
* the same tokens twice. `usage` is the source, full stop; addUsage() reads
|
|
225
|
+
* named fields only, so it cannot drift into iterations by accident.
|
|
226
|
+
*
|
|
227
|
+
* That rule has a price: when the attempts spent more than the top level
|
|
228
|
+
* reports, the row understates. So the condition is measured rather than
|
|
229
|
+
* assumed — if the iterations add up to more than the top level, the turn is
|
|
230
|
+
* counted here and the run says so.
|
|
231
|
+
*
|
|
232
|
+
* Measured over 29 transcripts (16 931 usage blocks carrying `iterations`):
|
|
233
|
+
* 0 blocks with more than one attempt, but 3 where the top-level usage is all
|
|
234
|
+
* zeros while the single iteration carries real tokens (228k cache reads in one
|
|
235
|
+
* case). TASK-086 assumed this counter would stay at zero; it does not.
|
|
236
|
+
*/
|
|
237
|
+
function usageUnderstated(usage) {
|
|
238
|
+
const its = Array.isArray(usage.iterations) ? usage.iterations : [];
|
|
239
|
+
if (its.length === 0) return false;
|
|
240
|
+
const top = emptyTokens();
|
|
241
|
+
addUsage(top, usage);
|
|
242
|
+
const attempts = emptyTokens();
|
|
243
|
+
for (const it of its) addUsage(attempts, it);
|
|
244
|
+
return totalTokens(attempts) > totalTokens(top);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Identity fields, from the first entry of the turn that carries each one. */
|
|
248
|
+
function applyIdentity(p, entry) {
|
|
249
|
+
if (p.cwd === null && typeof entry.cwd === 'string') p.cwd = entry.cwd;
|
|
250
|
+
if (p.ccVersion === null && typeof entry.version === 'string') p.ccVersion = entry.version;
|
|
251
|
+
// gitBranch is the ARM marker in the paired measurement (TASK-088), not
|
|
252
|
+
// decoration, so a change of branch inside one turn is flagged rather than
|
|
253
|
+
// averaged away — 19 of 495 turns in the live run.
|
|
254
|
+
if (typeof entry.gitBranch === 'string') {
|
|
255
|
+
if (p.gitBranch === null) p.gitBranch = entry.gitBranch;
|
|
256
|
+
else if (p.gitBranch !== entry.gitBranch) p.gitBranchChanged = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Turn boundaries and the longest silence inside them. */
|
|
261
|
+
function applyTiming(p, entry, isAssistant) {
|
|
262
|
+
const ts = msOf(entry);
|
|
263
|
+
if (ts === null) return;
|
|
264
|
+
if (p.tsSubmit === null || ts < p.tsSubmit) p.tsSubmit = ts;
|
|
265
|
+
if (p.tsEnd === null || ts > p.tsEnd) p.tsEnd = ts;
|
|
266
|
+
if (isAssistant && (p.tsFirstAssistant === null || ts < p.tsFirstAssistant)) p.tsFirstAssistant = ts;
|
|
267
|
+
// Largest silence between two consecutive entries of this turn. Same rule as
|
|
268
|
+
// the queued_s/work_s split, applied INSIDE the turn: a tool call waiting on
|
|
269
|
+
// a permission prompt is the user's time, not the tool's, and it lands in
|
|
270
|
+
// work_s indistinguishably. Not hypothetical — the live run over 29
|
|
271
|
+
// transcripts has a turn with work_s = 158999 (44 h). Measured here, judged
|
|
272
|
+
// in TASK-089: this file reports, it does not filter.
|
|
273
|
+
if (p.lastTs !== null && ts > p.lastTs) {
|
|
274
|
+
p.maxGap = Math.max(p.maxGap, ts - p.lastTs);
|
|
275
|
+
p.lastTs = ts;
|
|
276
|
+
} else if (p.lastTs === null) {
|
|
277
|
+
p.lastTs = ts;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* prompt_chars: what the USER sent. The first non-meta user entry that is not a
|
|
283
|
+
* tool result — later user entries in the group ARE tool results (934 of 1012
|
|
284
|
+
* in the measured session) and are not the prompt.
|
|
285
|
+
*/
|
|
286
|
+
function applyUserEntry(p, entry) {
|
|
287
|
+
if (p.promptChars !== null || entry.isMeta === true) return;
|
|
288
|
+
const content = entry.message?.content;
|
|
289
|
+
if (typeof content === 'string') p.promptChars = content.length;
|
|
290
|
+
else if (Array.isArray(content) && !content.some((b) => b?.type === 'tool_result')) {
|
|
291
|
+
p.promptChars = content.reduce((n, b) => n + (b?.type === 'text' ? (b.text?.length ?? 0) : 0), 0);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Tools, delegations, model/effort, and the per-model token split. */
|
|
296
|
+
function applyAssistantEntry(p, entry, { usage, model, synthetic }) {
|
|
297
|
+
for (const block of Array.isArray(entry.message?.content) ? entry.message.content : []) {
|
|
298
|
+
if (block?.type !== 'tool_use') continue;
|
|
299
|
+
const name = typeof block.name === 'string' ? block.name : '(unknown)';
|
|
300
|
+
p.tools.set(name, (p.tools.get(name) ?? 0) + 1);
|
|
301
|
+
p.toolCalls++;
|
|
302
|
+
if (AGENT_TOOLS.has(name)) p.agentSpawns++;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Counted, then dropped from every billing figure: Claude Code wrote it, the
|
|
306
|
+
// API did not bill it.
|
|
307
|
+
if (synthetic) {
|
|
308
|
+
p.syntheticTurns++;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
p.apiTurns++;
|
|
313
|
+
if (model) p.models.add(model);
|
|
314
|
+
if (typeof entry.effort === 'string') p.efforts.add(entry.effort);
|
|
315
|
+
if (!usage) return;
|
|
316
|
+
if (typeof usage.speed === 'string') p.speeds.add(usage.speed);
|
|
317
|
+
if (typeof usage.service_tier === 'string') p.tiers.add(usage.service_tier);
|
|
318
|
+
if (typeof usage.inference_geo === 'string') p.geos.add(usage.inference_geo);
|
|
319
|
+
// Per-model split, so a turn that switched models is still priceable exactly
|
|
320
|
+
// instead of being charged at one model's rate (error class 4). The row's
|
|
321
|
+
// flat totals are folded back out of this in toRow() — one source, no drift.
|
|
322
|
+
const key = model ?? '(unknown)';
|
|
323
|
+
if (!p.byModel.has(key)) p.byModel.set(key, { api_turns: 0, ...emptyTokens() });
|
|
324
|
+
const bucket = p.byModel.get(key);
|
|
325
|
+
bucket.api_turns++;
|
|
326
|
+
addUsage(bucket, usage);
|
|
327
|
+
|
|
328
|
+
if (Array.isArray(usage.iterations) && usage.iterations.length > 1) p.multiAttemptTurns++;
|
|
329
|
+
if (usageUnderstated(usage)) p.understatedTurns++;
|
|
330
|
+
// Billed per request, not per token, so they sit outside list_cost_by_class.
|
|
331
|
+
// Counted so the gap announces itself instead of hiding in a clean total.
|
|
332
|
+
const server = usage.server_tool_use;
|
|
333
|
+
if (server) {
|
|
334
|
+
p.webSearchRequests += server.web_search_requests || 0;
|
|
335
|
+
p.webFetchRequests += server.web_fetch_requests || 0;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Streaming aggregator. One accumulator per turn, fed entry by entry in file
|
|
341
|
+
* order, so the caller never has to materialize a transcript (criterion 4).
|
|
342
|
+
*
|
|
343
|
+
* Attribution: `promptId` sits on `user` entries only — including the tool
|
|
344
|
+
* RESULTS, which come back as `user` entries, so it groups the whole turn.
|
|
345
|
+
* `assistant` entries carry no promptId and are resolved by walking `parentUuid`
|
|
346
|
+
* to the nearest ancestor that has one (2517/2517 in the measured session).
|
|
347
|
+
* The walk is O(1) per entry because every uuid seen is memoized with the group
|
|
348
|
+
* it resolved to, and parents always precede children in the file.
|
|
349
|
+
*
|
|
350
|
+
* Entries of ANY type are memoized, not just user/assistant: a `system` or
|
|
351
|
+
* `attachment` entry can sit in the middle of a chain, and skipping it would
|
|
352
|
+
* break the link for everything below it.
|
|
353
|
+
*
|
|
354
|
+
* An entry that resolves to nothing is counted into `unattributed` — with its
|
|
355
|
+
* tokens — and reported. Silently dropping it is the failure this file exists
|
|
356
|
+
* to avoid (criterion 2).
|
|
357
|
+
*/
|
|
358
|
+
export function createCollector() {
|
|
359
|
+
// Keyed by (session, promptId), never by promptId alone. Nothing documents
|
|
360
|
+
// promptId as globally unique — it is unique across the 29 local transcripts,
|
|
361
|
+
// which is a property of that sample, not of the format. If two sessions ever
|
|
362
|
+
// shared one, keying on it alone would merge their rows, and since the arm is
|
|
363
|
+
// a property of the SESSION (EPIC-009, error class 6) that merge would blend
|
|
364
|
+
// two arms into a single row and look like ordinary data.
|
|
365
|
+
const prompts = new Map(); // key -> accumulator, in first-seen order
|
|
366
|
+
const groups = new Map(); // key -> {key, promptId, session}, shared by memoized uuids
|
|
367
|
+
let uuidGroup = new Map(); // uuid -> group | null; reset per file (see beginFile)
|
|
368
|
+
const unattributed = { entries: 0, turns: 0, ...emptyTokens() };
|
|
369
|
+
const totals = { malformed: 0, entries: 0, files: 0 };
|
|
370
|
+
|
|
371
|
+
function groupFor(entry) {
|
|
372
|
+
// Direct: `user` entries (prompt AND tool results) carry promptId.
|
|
373
|
+
const promptId = entry.promptId;
|
|
374
|
+
if (typeof promptId === 'string' && promptId !== '') {
|
|
375
|
+
const session = typeof entry.sessionId === 'string' ? entry.sessionId : null;
|
|
376
|
+
const key = `${session ?? ''}\u0000${promptId}`;
|
|
377
|
+
let group = groups.get(key);
|
|
378
|
+
if (!group) {
|
|
379
|
+
group = { key, promptId, session };
|
|
380
|
+
groups.set(key, group);
|
|
381
|
+
}
|
|
382
|
+
return group;
|
|
383
|
+
}
|
|
384
|
+
// Inherited: nearest ancestor with one. Ancestors are already memoized, and
|
|
385
|
+
// a memoized `null` correctly propagates "unattributable" down the chain.
|
|
386
|
+
const parent = entry.parentUuid;
|
|
387
|
+
return typeof parent === 'string' ? (uuidGroup.get(parent) ?? null) : null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function addEntry(entry) {
|
|
391
|
+
totals.entries++;
|
|
392
|
+
const group = groupFor(entry);
|
|
393
|
+
// Memoize BEFORE any type filtering: unknown types are links in the chain
|
|
394
|
+
// even when they carry no metric of their own.
|
|
395
|
+
if (typeof entry.uuid === 'string') uuidGroup.set(entry.uuid, group);
|
|
396
|
+
|
|
397
|
+
const type = entry.type;
|
|
398
|
+
const isAssistant = type === 'assistant';
|
|
399
|
+
// Everything else in a live transcript — queue-operation, ai-title,
|
|
400
|
+
// file-history-snapshot/delta, last-prompt, attachment, system, mode —
|
|
401
|
+
// carries no per-prompt metric. Skipped, never fatal.
|
|
402
|
+
if (type !== 'user' && !isAssistant) return;
|
|
403
|
+
|
|
404
|
+
const usage = entry.message?.usage;
|
|
405
|
+
const model = entry.message?.model ?? null;
|
|
406
|
+
const synthetic = isAssistant && model === SYNTHETIC_MODEL;
|
|
407
|
+
|
|
408
|
+
if (group === null) {
|
|
409
|
+
unattributed.entries++;
|
|
410
|
+
if (isAssistant && !synthetic) {
|
|
411
|
+
unattributed.turns++;
|
|
412
|
+
if (usage) addUsage(unattributed, usage);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Created here, not in groupFor(): an entry of an unknown type must never
|
|
418
|
+
// conjure a row of zeros just by carrying a promptId.
|
|
419
|
+
let p = prompts.get(group.key);
|
|
420
|
+
if (!p) {
|
|
421
|
+
p = newPrompt(group);
|
|
422
|
+
prompts.set(group.key, p);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
applyIdentity(p, entry);
|
|
426
|
+
applyTiming(p, entry, isAssistant);
|
|
427
|
+
if (entry.isSidechain === true && isAssistant) p.sidechainTurns++;
|
|
428
|
+
if (isAssistant) applyAssistantEntry(p, entry, { usage, model, synthetic });
|
|
429
|
+
else applyUserEntry(p, entry);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function addLine(line) {
|
|
433
|
+
const entry = parseEntry(line);
|
|
434
|
+
// A truncated or corrupted line costs exactly that line (criterion 3): a
|
|
435
|
+
// transcript is appended to live, so the last line can be half-written at
|
|
436
|
+
// any moment. Counted, so a run over garbage doesn't look like a clean run.
|
|
437
|
+
if (entry === null) {
|
|
438
|
+
if (typeof line === 'string' && line.trim() !== '') totals.malformed++;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
addEntry(entry);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function toRow(p) {
|
|
445
|
+
const queued_s = seconds(p.tsSubmit, p.tsFirstAssistant);
|
|
446
|
+
const work_s = seconds(p.tsFirstAssistant, p.tsEnd);
|
|
447
|
+
// Flat totals are the fold of the per-model split, not a second running
|
|
448
|
+
// sum: EPIC-009 criterion 2 forbids printing a token figure that disagrees
|
|
449
|
+
// with its own breakdown, and the cheapest way to guarantee that is to have
|
|
450
|
+
// only one number to disagree with.
|
|
451
|
+
const tokens = emptyTokens();
|
|
452
|
+
for (const bucket of p.byModel.values()) {
|
|
453
|
+
for (const field of Object.keys(tokens)) tokens[field] += bucket[field];
|
|
454
|
+
}
|
|
455
|
+
const row = {
|
|
456
|
+
promptId: p.promptId,
|
|
457
|
+
session: p.session,
|
|
458
|
+
cwd: p.cwd,
|
|
459
|
+
// Always written, empty string included: an absent arm marker is data
|
|
460
|
+
// (TASK-088), and a row that omits the field would be indistinguishable
|
|
461
|
+
// from a row the parser failed on.
|
|
462
|
+
gitBranch: p.gitBranch ?? '',
|
|
463
|
+
gitBranchChanged: p.gitBranchChanged,
|
|
464
|
+
cc_version: p.ccVersion,
|
|
465
|
+
ts_submit: isoOrNull(p.tsSubmit),
|
|
466
|
+
ts_first_assistant: isoOrNull(p.tsFirstAssistant),
|
|
467
|
+
ts_end: isoOrNull(p.tsEnd),
|
|
468
|
+
// Split deliberately. A single duration measured from submission swallows
|
|
469
|
+
// queue wait — 84 `enqueue` events in one measured session — and that is
|
|
470
|
+
// the user's time, not the tool's. TASK-089 compares work_s.
|
|
471
|
+
queued_s,
|
|
472
|
+
work_s,
|
|
473
|
+
max_gap_s: p.maxGap / 1000,
|
|
474
|
+
model: collapse(p.models),
|
|
475
|
+
models: [...p.models].sort(),
|
|
476
|
+
effort: collapse(p.efforts),
|
|
477
|
+
efforts: [...p.efforts].sort(),
|
|
478
|
+
speed: collapse(p.speeds),
|
|
479
|
+
service_tier: collapse(p.tiers),
|
|
480
|
+
inference_geo: collapse(p.geos),
|
|
481
|
+
api_turns: p.apiTurns,
|
|
482
|
+
synthetic_turns: p.syntheticTurns,
|
|
483
|
+
...tokens,
|
|
484
|
+
by_model: Object.fromEntries([...p.byModel.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
|
|
485
|
+
tools: Object.fromEntries([...p.tools.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))),
|
|
486
|
+
tool_calls_total: p.toolCalls,
|
|
487
|
+
agent_spawns: p.agentSpawns,
|
|
488
|
+
sidechain_turns: p.sidechainTurns,
|
|
489
|
+
multi_attempt_turns: p.multiAttemptTurns,
|
|
490
|
+
understated_turns: p.understatedTurns,
|
|
491
|
+
web_search_requests: p.webSearchRequests,
|
|
492
|
+
web_fetch_requests: p.webFetchRequests,
|
|
493
|
+
prompt_chars: p.promptChars ?? 0,
|
|
494
|
+
};
|
|
495
|
+
// Priced from the row that was just built, so the dollars can only ever be
|
|
496
|
+
// a function of the published components — nothing is billed that the row
|
|
497
|
+
// does not also show (EPIC-009 criterion 2). Priced at the prompt's own
|
|
498
|
+
// timestamp, so re-running this next year yields the same money.
|
|
499
|
+
return { ...row, ...priceRow(row) };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
addLine,
|
|
504
|
+
addEntry,
|
|
505
|
+
/**
|
|
506
|
+
* Start of a new transcript. Drops the uuid index, because a parentUuid
|
|
507
|
+
* chain never crosses a file — so keeping it would only let one file's uuid
|
|
508
|
+
* capture another file's entry, and would grow the index across a whole
|
|
509
|
+
* scan (17 403 uuids over the 29 local transcripts) for no gain. The
|
|
510
|
+
* per-turn accumulators survive: rows accumulate across files by design.
|
|
511
|
+
*/
|
|
512
|
+
beginFile() {
|
|
513
|
+
totals.files++;
|
|
514
|
+
uuidGroup = new Map();
|
|
515
|
+
},
|
|
516
|
+
result() {
|
|
517
|
+
const rows = [...prompts.values()].map(toRow);
|
|
518
|
+
const sum = (field) => rows.reduce((n, r) => n + r[field], 0);
|
|
519
|
+
const tokens = emptyTokens();
|
|
520
|
+
for (const row of rows) for (const field of Object.keys(tokens)) tokens[field] += row[field];
|
|
521
|
+
return {
|
|
522
|
+
rows,
|
|
523
|
+
// Reported on every run, never inferred from `rows` being short.
|
|
524
|
+
unattributed,
|
|
525
|
+
tokens,
|
|
526
|
+
cost: summarizeCost(rows),
|
|
527
|
+
totals: {
|
|
528
|
+
...totals,
|
|
529
|
+
prompts: rows.length,
|
|
530
|
+
agent_spawns: sum('agent_spawns'),
|
|
531
|
+
sidechain_turns: sum('sidechain_turns'),
|
|
532
|
+
synthetic_turns: sum('synthetic_turns'),
|
|
533
|
+
multi_attempt_turns: sum('multi_attempt_turns'),
|
|
534
|
+
understated_turns: sum('understated_turns'),
|
|
535
|
+
web_search_requests: sum('web_search_requests'),
|
|
536
|
+
web_fetch_requests: sum('web_fetch_requests'),
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Convenience wrapper for tests and small inputs: entries in, result out. */
|
|
544
|
+
export function aggregate(entries) {
|
|
545
|
+
const c = createCollector();
|
|
546
|
+
for (const entry of entries) c.addEntry(entry);
|
|
547
|
+
return c.result();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function formatRow(row) {
|
|
551
|
+
return JSON.stringify(row);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Human-readable run summary. Goes to stderr so stdout stays parseable JSONL.
|
|
556
|
+
* Names the two things that are NOT in the numbers — unattributed entries and
|
|
557
|
+
* the uncaptured cost of delegation — because a summary that only reports what
|
|
558
|
+
* it measured is how a blind spot becomes a clean-looking result.
|
|
559
|
+
*/
|
|
560
|
+
export function formatSummary({ totals, unattributed, cost, tokens }) {
|
|
561
|
+
const L = [];
|
|
562
|
+
L.push(
|
|
563
|
+
`prompt-metrics: ${totals.prompts} prompt(s) from ${totals.entries} entries` +
|
|
564
|
+
` in ${totals.files} transcript(s)` +
|
|
565
|
+
(totals.malformed > 0 ? `; ${totals.malformed} malformed line(s) skipped` : ''),
|
|
566
|
+
);
|
|
567
|
+
L.push(
|
|
568
|
+
` unattributed entries: ${unattributed.entries}` +
|
|
569
|
+
` (${unattributed.turns} API turn(s), ${unattributed.output_tokens} output tokens) — not in any row`,
|
|
570
|
+
);
|
|
571
|
+
L.push(
|
|
572
|
+
` subagent spawns: ${totals.agent_spawns}; sidechain turns in transcript: ${totals.sidechain_turns}` +
|
|
573
|
+
(totals.agent_spawns > 0 && totals.sidechain_turns === 0
|
|
574
|
+
? ' — subagent cost is UNCAPTURED, not zero (EPIC-009 class 3)'
|
|
575
|
+
: ''),
|
|
576
|
+
);
|
|
577
|
+
if (totals.synthetic_turns > 0) {
|
|
578
|
+
L.push(` <synthetic> turns excluded from tokens/turns: ${totals.synthetic_turns}`);
|
|
579
|
+
}
|
|
580
|
+
if (cost) L.push(...formatCostLines(cost, tokens));
|
|
581
|
+
if (totals.understated_turns > 0 || totals.multi_attempt_turns > 0) {
|
|
582
|
+
L.push(
|
|
583
|
+
` turns where usage.iterations exceeds the reported usage: ${totals.understated_turns}` +
|
|
584
|
+
` (${totals.multi_attempt_turns} with >1 attempt) — cost above is a known UNDERestimate`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return L.join('\n');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Lines of a file, without ever holding more than a buffer of it. Transcripts
|
|
592
|
+
* reach tens of MB (16.6MB locally), and a tool whose own footprint grows with
|
|
593
|
+
* session length is the cost curve this project exists to fight — the same rule
|
|
594
|
+
* the context-reminder hook follows with readTail(). That hook needs only the
|
|
595
|
+
* tail; here every line matters, so it is a line stream rather than a slice.
|
|
596
|
+
*/
|
|
597
|
+
export async function* readLines(path) {
|
|
598
|
+
const rl = createInterface({ input: createReadStream(path, 'utf8'), crlfDelay: Infinity });
|
|
599
|
+
try {
|
|
600
|
+
for await (const line of rl) yield line;
|
|
601
|
+
} finally {
|
|
602
|
+
rl.close();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export async function collectFiles(paths, collector = createCollector()) {
|
|
607
|
+
for (const path of paths) {
|
|
608
|
+
collector.beginFile();
|
|
609
|
+
for await (const line of readLines(path)) collector.addLine(line);
|
|
610
|
+
}
|
|
611
|
+
return collector;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Transcripts directly in `dir` or in its immediate subdirectories, sorted. */
|
|
615
|
+
export function findTranscripts(dir) {
|
|
616
|
+
if (!existsSync(dir)) return [];
|
|
617
|
+
const out = [];
|
|
618
|
+
const visit = (base, depth) => {
|
|
619
|
+
let names;
|
|
620
|
+
try {
|
|
621
|
+
names = readdirSync(base);
|
|
622
|
+
} catch {
|
|
623
|
+
return; // unreadable dir — skip, never fatal
|
|
624
|
+
}
|
|
625
|
+
for (const name of names.sort()) {
|
|
626
|
+
const full = join(base, name);
|
|
627
|
+
let st;
|
|
628
|
+
try {
|
|
629
|
+
st = statSync(full);
|
|
630
|
+
} catch {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (st.isDirectory()) {
|
|
634
|
+
if (depth > 0) visit(full, depth - 1);
|
|
635
|
+
} else if (name.endsWith('.jsonl')) {
|
|
636
|
+
out.push(full);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
visit(dir, 1);
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function defaultDir() {
|
|
645
|
+
return join(claudeBaseDir(), 'projects');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function main() {
|
|
649
|
+
let paths;
|
|
650
|
+
try {
|
|
651
|
+
const { values } = parseArgs({
|
|
652
|
+
args: process.argv.slice(2),
|
|
653
|
+
options: {
|
|
654
|
+
transcript: { type: 'string', multiple: true },
|
|
655
|
+
dir: { type: 'string' },
|
|
656
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
657
|
+
},
|
|
658
|
+
strict: true,
|
|
659
|
+
});
|
|
660
|
+
if (values.help) {
|
|
661
|
+
process.stdout.write(`${USAGE}\n`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Both flags together are a UNION, deduplicated, not a precedence rule:
|
|
665
|
+
// silently ignoring one of two flags the user passed is how a run measures
|
|
666
|
+
// something other than what was asked for.
|
|
667
|
+
const explicit = values.transcript ?? [];
|
|
668
|
+
const scanned = values.dir !== undefined || explicit.length === 0 ? findTranscripts(values.dir ?? defaultDir()) : [];
|
|
669
|
+
paths = [...new Set([...explicit, ...scanned])];
|
|
670
|
+
const missing = paths.filter((p) => !existsSync(p));
|
|
671
|
+
if (missing.length > 0) {
|
|
672
|
+
// Same shape as adoption-report's missing-log case: say where it looked,
|
|
673
|
+
// change nothing else, exit 0. Note goes to stderr because stdout here is
|
|
674
|
+
// a data channel, not a report.
|
|
675
|
+
for (const p of missing) process.stderr.write(`prompt-metrics: no transcript at ${p}\n`);
|
|
676
|
+
paths = paths.filter((p) => existsSync(p));
|
|
677
|
+
}
|
|
678
|
+
if (paths.length === 0) {
|
|
679
|
+
process.stderr.write(
|
|
680
|
+
`prompt-metrics: no transcripts found in ${values.dir ?? defaultDir()}.\n` +
|
|
681
|
+
'(Claude Code writes them to <config dir>/projects/<slug>/<session>.jsonl.)\n',
|
|
682
|
+
);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
} catch (e) {
|
|
686
|
+
process.stderr.write(`prompt-metrics: ${e.message}\n`);
|
|
687
|
+
process.exitCode = 1;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Piping into `head` closes stdout early; that is a normal end, not a crash.
|
|
692
|
+
process.stdout.on('error', (e) => {
|
|
693
|
+
if (e.code !== 'EPIPE') throw e;
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const collector = await collectFiles(paths);
|
|
697
|
+
const result = collector.result();
|
|
698
|
+
|
|
699
|
+
// The arm is added here, not in the collector: the collector turns a
|
|
700
|
+
// transcript into rows and has no business knowing about the experiment, and
|
|
701
|
+
// the ledger is machine state rather than transcript content. The split also
|
|
702
|
+
// lets the same rules run over rows collected elsewhere (TASK-088).
|
|
703
|
+
const ledgerPath = experimentPaths(claudeBaseDir()).ledgerPath;
|
|
704
|
+
const ledgerAvailable = existsSync(ledgerPath);
|
|
705
|
+
const rows = annotateRows(result.rows, {
|
|
706
|
+
ledger: ledgerAvailable ? readLedger(claudeBaseDir()) : [],
|
|
707
|
+
ledgerAvailable,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
for (const row of rows) process.stdout.write(`${formatRow(row)}\n`);
|
|
711
|
+
process.stderr.write(`${formatSummary(result)}\n`);
|
|
712
|
+
const arms = formatArmSummary(rows);
|
|
713
|
+
if (arms !== null) process.stderr.write(`${arms}\n`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (process.argv[1] && process.argv[1].endsWith('prompt-metrics.mjs')) {
|
|
717
|
+
await main();
|
|
718
|
+
}
|