glassbox-trace 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,768 @@
1
+ /*
2
+ * Glassbox trace core — parse, diagnose, cost, redact.
3
+ * Pure JS, no DOM, no dependencies. Runs in Node (tests) and inlined in the viewer.
4
+ */
5
+ (function (root, factory) {
6
+ const api = factory();
7
+ if (typeof module === 'object' && module.exports) module.exports = api;
8
+ root.TraceCore = api;
9
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
10
+ 'use strict';
11
+
12
+ const VERSION = '0.3.0';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Rate card (USD per million tokens). Prefix-matched against model ids so dated
16
+ // ids like claude-sonnet-4-5-20250929 resolve. Longest prefix wins.
17
+ // Source: platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-05.
18
+ // ---------------------------------------------------------------------------
19
+ const RATES = {
20
+ 'claude-fable-5-1': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 0.25 },
21
+ 'claude-mythos-5-1': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 0.25 },
22
+ 'claude-fable-5': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 1.0 },
23
+ 'claude-mythos-5': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 1.0 },
24
+ 'claude-opus-5': { in: 5, out: 25, w5m: 6.25, w1h: 10, read: 0.5 },
25
+ 'claude-opus-4-8': { in: 5, out: 25, w5m: 6.25, w1h: 10, read: 0.5 },
26
+ 'claude-opus-4-7': { in: 5, out: 25, w5m: 6.25, w1h: 10, read: 0.5 },
27
+ 'claude-opus-4-6': { in: 5, out: 25, w5m: 6.25, w1h: 10, read: 0.5 },
28
+ 'claude-opus-4-5': { in: 5, out: 25, w5m: 6.25, w1h: 10, read: 0.5 },
29
+ 'claude-opus-4-1': { in: 15, out: 75, w5m: 18.75, w1h: 30, read: 1.5 },
30
+ 'claude-opus-4-2025': { in: 15, out: 75, w5m: 18.75, w1h: 30, read: 1.5 },
31
+ 'claude-sonnet-5': { in: 2, out: 10, w5m: 2.5, w1h: 4, read: 0.2 },
32
+ 'claude-sonnet-4': { in: 3, out: 15, w5m: 3.75, w1h: 6, read: 0.3 },
33
+ 'claude-haiku-4-5': { in: 1, out: 5, w5m: 1.25, w1h: 2, read: 0.1 },
34
+ 'claude-3-5-haiku': { in: 0.8, out: 4, w5m: 1.0, w1h: 1.6, read: 0.08 },
35
+ };
36
+
37
+ const DEFAULTS = {
38
+ retryLoopMin: 3,
39
+ explorationRunInfo: 8,
40
+ explorationRunWarn: 15,
41
+ oversizedResultWarn: 20000,
42
+ oversizedResultError: 60000,
43
+ imageHeavyInfoBytes: 512 * 1024,
44
+ imageHeavyWarnBytes: 2 * 1048576,
45
+ contextBloatWarn: 120000,
46
+ contextBloatError: 170000,
47
+ cacheChurnMin: 20000,
48
+ lowCacheHitRatio: 0.5,
49
+ lowCacheHitMinRequests: 5,
50
+ slowToolInfoMs: 60000,
51
+ slowToolWarnMs: 300000,
52
+ slowModelMs: 60000,
53
+ slowModelMaxOutput: 1500,
54
+ thinkingHeavyShare: 0.6,
55
+ thinkingHeavyMinOutput: 10000,
56
+ subagentShare: 0.5,
57
+ longTurnTools: 30,
58
+ failedToolRate: 0.5,
59
+ failedToolMinCalls: 3,
60
+ };
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Helpers
64
+ // ---------------------------------------------------------------------------
65
+ const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'ToolSearch', 'TaskGet', 'TaskList', 'ListSkills', 'ReadNotifications']);
66
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
67
+ const EXEC_TOOLS = new Set(['Bash', 'BashOutput', 'KillShell']);
68
+ const AGENT_TOOLS = new Set(['Agent', 'Task', 'Workflow']);
69
+ const USER_TOOLS = new Set(['AskUserQuestion']);
70
+
71
+ function toolCategory(name) {
72
+ if (!name) return 'other';
73
+ if (READ_TOOLS.has(name)) return 'read';
74
+ if (WRITE_TOOLS.has(name)) return 'write';
75
+ if (EXEC_TOOLS.has(name) || /device_bash$/.test(name)) return 'exec';
76
+ if (AGENT_TOOLS.has(name)) return 'agent';
77
+ if (USER_TOOLS.has(name)) return 'user';
78
+ if (name.startsWith('mcp__')) {
79
+ const leaf = name.split('__').pop() || '';
80
+ if (/^(get|list|search|read|find|fetch|query|describe|show)/i.test(leaf)) return 'read';
81
+ return 'mcp';
82
+ }
83
+ return 'other';
84
+ }
85
+
86
+ function ts(rec) {
87
+ if (!rec) return null;
88
+ const t = rec.timestamp;
89
+ if (t == null) return null;
90
+ if (typeof t === 'number') return t > 1e12 ? t : t * 1000;
91
+ const ms = Date.parse(t);
92
+ return Number.isFinite(ms) ? ms : null;
93
+ }
94
+
95
+ function textOf(content) {
96
+ if (content == null) return '';
97
+ if (typeof content === 'string') return content;
98
+ if (Array.isArray(content)) {
99
+ return content.map((b) => {
100
+ if (typeof b === 'string') return b;
101
+ if (!b || typeof b !== 'object') return '';
102
+ if (b.type === 'text') return b.text || '';
103
+ if (b.type === 'tool_result') return textOf(b.content);
104
+ if (b.type === 'thinking') return '';
105
+ return '';
106
+ }).join('');
107
+ }
108
+ if (typeof content === 'object') return JSON.stringify(content);
109
+ return String(content);
110
+ }
111
+
112
+ function stableStringify(v) {
113
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
114
+ if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
115
+ return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
116
+ }
117
+
118
+ function emptyUsage() {
119
+ return { input: 0, cacheRead: 0, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0, output: 0, thinking: 0 };
120
+ }
121
+
122
+ function addUsage(a, b) {
123
+ for (const k of Object.keys(a)) a[k] += b[k] || 0;
124
+ return a;
125
+ }
126
+
127
+ function usageFrom(u) {
128
+ const out = emptyUsage();
129
+ if (!u || typeof u !== 'object') return out;
130
+ out.input = u.input_tokens || 0;
131
+ out.cacheRead = u.cache_read_input_tokens || 0;
132
+ out.cacheWrite = u.cache_creation_input_tokens || 0;
133
+ const cc = u.cache_creation || {};
134
+ out.cacheWrite5m = cc.ephemeral_5m_input_tokens || 0;
135
+ out.cacheWrite1h = cc.ephemeral_1h_input_tokens || 0;
136
+ if (!out.cacheWrite5m && !out.cacheWrite1h) out.cacheWrite5m = out.cacheWrite; // default to 5m when unsplit
137
+ out.output = u.output_tokens || 0;
138
+ out.thinking = (u.output_tokens_details && u.output_tokens_details.thinking_tokens) || 0;
139
+ return out;
140
+ }
141
+
142
+ function isEmptyUsage(u) {
143
+ return !u.input && !u.cacheRead && !u.cacheWrite && !u.output;
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Line parsing
148
+ // ---------------------------------------------------------------------------
149
+ function parseLines(text, fileName) {
150
+ const records = [];
151
+ const problems = [];
152
+ if (typeof text !== 'string') return { records, problems: [{ file: fileName, line: 0, reason: 'not text' }] };
153
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
154
+ const trimmed = text.trim();
155
+ // A whole-file JSON array (Messages API dump or SDK output captured as JSON)
156
+ if (trimmed.startsWith('[')) {
157
+ try {
158
+ const arr = JSON.parse(trimmed);
159
+ if (Array.isArray(arr)) {
160
+ arr.forEach((r, i) => records.push(normaliseLoose(r, i, fileName)));
161
+ return { records, problems };
162
+ }
163
+ } catch (e) { /* fall through to line mode */ }
164
+ }
165
+ const lines = text.split(/\r?\n/);
166
+ for (let i = 0; i < lines.length; i++) {
167
+ const line = lines[i].trim();
168
+ if (!line) continue;
169
+ try {
170
+ const r = JSON.parse(line);
171
+ if (r && typeof r === 'object') records.push(normaliseLoose(r, i + 1, fileName));
172
+ else problems.push({ file: fileName, line: i + 1, reason: 'not an object' });
173
+ } catch (e) {
174
+ problems.push({ file: fileName, line: i + 1, reason: 'bad JSON: ' + String(e.message).slice(0, 80) });
175
+ }
176
+ }
177
+ return { records, problems };
178
+ }
179
+
180
+ // Accept Messages-API {role, content} objects as user/assistant records.
181
+ function normaliseLoose(r, line, fileName) {
182
+ if (r && !r.type && r.role && r.content !== undefined) {
183
+ return { type: r.role, message: { role: r.role, content: r.content, usage: r.usage, model: r.model }, _loose: true, _line: line, _file: fileName };
184
+ }
185
+ r._line = line;
186
+ r._file = fileName;
187
+ return r;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Trace building
192
+ // ---------------------------------------------------------------------------
193
+ function parseTrace(files, opts) {
194
+ opts = opts || {};
195
+ if (!Array.isArray(files)) files = [files];
196
+ const allRecords = [];
197
+ const problems = [];
198
+ const fileMeta = [];
199
+ const subagentMeta = {}; // agentId -> meta.json content
200
+
201
+ for (const f of files) {
202
+ const name = f.name || 'transcript.jsonl';
203
+ if (/\.meta\.json$/.test(name)) {
204
+ try {
205
+ const m = JSON.parse(f.text);
206
+ const id = (name.match(/agent-([^/\\]+)\.meta\.json$/) || [])[1];
207
+ if (id) subagentMeta[id] = m;
208
+ } catch (e) { problems.push({ file: name, line: 0, reason: 'bad meta.json' }); }
209
+ continue;
210
+ }
211
+ const { records, problems: p } = parseLines(f.text, name);
212
+ problems.push(...p);
213
+ fileMeta.push({ name, records: records.length, bytes: f.text ? f.text.length : 0 });
214
+ allRecords.push(...records);
215
+ }
216
+
217
+ // Stable sort by timestamp where available; keep file order otherwise.
218
+ // Records without a timestamp (last-prompt, atis-latch, summary…) inherit the previous
219
+ // record's time in their file so the sort key is total and files interleave correctly.
220
+ allRecords.forEach((r, i) => { r._seq = i; });
221
+ const hasTimestamps = allRecords.some((r) => ts(r) != null);
222
+ if (hasTimestamps) {
223
+ let last = null, lastFile = null;
224
+ for (const r of allRecords) {
225
+ if (r._file !== lastFile) { lastFile = r._file; last = null; }
226
+ const t = ts(r);
227
+ if (t != null) last = t; else r._t = last;
228
+ }
229
+ const key = (r) => { const t = ts(r); return t != null ? t : (r._t != null ? r._t : -Infinity); };
230
+ allRecords.sort((a, b) => (key(a) - key(b)) || (a._seq - b._seq));
231
+ }
232
+
233
+ const meta = {
234
+ sessionId: null, version: null, cwd: null, gitBranch: null, entrypoint: null, title: null,
235
+ models: [], start: null, end: null, hasTimestamps, files: fileMeta, format: 'claude-code',
236
+ reportedCost: null, reportedUsage: null, recordCounts: {},
237
+ };
238
+
239
+ const agents = new Map(); // id -> agent
240
+ function agentFor(rec) {
241
+ const id = rec.agentId || (rec.isSidechain ? 'sidechain' : 'main');
242
+ if (!agents.has(id)) {
243
+ agents.set(id, {
244
+ id, type: id === 'main' ? 'main' : 'subagent', description: null, parentToolUseId: null,
245
+ start: null, end: null, usage: emptyUsage(), requestCount: 0, toolCount: 0, model: null,
246
+ });
247
+ }
248
+ const a = agents.get(id);
249
+ const t = ts(rec);
250
+ if (t != null) { if (a.start == null || t < a.start) a.start = t; if (a.end == null || t > a.end) a.end = t; }
251
+ return a;
252
+ }
253
+ agentFor({ agentId: null }); // ensure main exists first
254
+
255
+ const requests = [];
256
+ const requestByKey = new Map();
257
+ const toolCalls = [];
258
+ const toolCallById = new Map();
259
+ const turns = [];
260
+ const events = [];
261
+ const attachments = { count: 0, chars: 0 };
262
+ let lastAssistantEnd = null; // timestamp of last assistant record (main agent)
263
+ let lastAssistantEndTurn = null;
264
+ let lastToolResultAt = null;
265
+
266
+ function currentTurn(agentId) {
267
+ for (let i = turns.length - 1; i >= 0; i--) if (turns[i].agent === agentId) return turns[i];
268
+ return null;
269
+ }
270
+ function ensureTurn(rec, kind, promptText) {
271
+ const a = agentFor(rec);
272
+ const t = ts(rec);
273
+ const prev = currentTurn(a.id);
274
+ if (prev && prev.end == null && t != null) prev.end = t;
275
+ const turn = {
276
+ index: turns.length, agent: a.id, promptText: promptText || '', promptKind: kind, start: t, end: null,
277
+ requestIds: [], toolCallIds: [], usage: emptyUsage(), idleBeforeMs: 0, recordUuid: rec.uuid || null,
278
+ };
279
+ if (a.id === 'main' && kind === 'human' && lastAssistantEndTurn != null && t != null) {
280
+ turn.idleBeforeMs = Math.max(0, t - lastAssistantEndTurn);
281
+ }
282
+ turns.push(turn);
283
+ return turn;
284
+ }
285
+
286
+ for (const rec of allRecords) {
287
+ const type = rec.type;
288
+ meta.recordCounts[type] = (meta.recordCounts[type] || 0) + 1;
289
+ if (!meta.sessionId && rec.sessionId) meta.sessionId = rec.sessionId;
290
+ if (!meta.sessionId && rec.session_id) meta.sessionId = rec.session_id;
291
+ if (!meta.version && rec.version) meta.version = rec.version;
292
+ if (!meta.cwd && rec.cwd) meta.cwd = rec.cwd;
293
+ if (!meta.gitBranch && rec.gitBranch) meta.gitBranch = rec.gitBranch;
294
+ if (!meta.entrypoint && rec.entrypoint) meta.entrypoint = rec.entrypoint;
295
+ const t = ts(rec);
296
+ if (t != null) { if (meta.start == null || t < meta.start) meta.start = t; if (meta.end == null || t > meta.end) meta.end = t; }
297
+
298
+ if (type === 'summary') { if (rec.summary && !meta.title) meta.title = rec.summary; events.push({ kind: 'summary', at: t, detail: rec.summary }); continue; }
299
+ if (type === 'attachment') { attachments.count++; attachments.chars += JSON.stringify(rec.attachment || '').length; continue; }
300
+ if (type === 'result') {
301
+ meta.format = 'stream-json';
302
+ if (typeof rec.total_cost_usd === 'number') meta.reportedCost = rec.total_cost_usd;
303
+ if (rec.usage) meta.reportedUsage = usageFrom(rec.usage);
304
+ if (rec.is_error) events.push({ kind: 'api_error', at: t, detail: textOf(rec.result) || 'result is_error' });
305
+ continue;
306
+ }
307
+ if (type === 'system') {
308
+ if (rec.subtype === 'init') { meta.format = 'stream-json'; if (rec.model) pushModel(meta, rec.model); continue; }
309
+ if (rec.subtype === 'compact_boundary') {
310
+ const cm = rec.compactMetadata || {};
311
+ events.push({ kind: 'compact', at: t, detail: (cm.trigger || 'compaction') + (cm.preTokens ? ' at ' + cm.preTokens + ' tokens' : ''), preTokens: cm.preTokens || null, agent: agentFor(rec).id });
312
+ continue;
313
+ }
314
+ if (rec.subtype === 'stop_hook_summary') {
315
+ if (Array.isArray(rec.hookErrors) && rec.hookErrors.length) events.push({ kind: 'hook_error', at: t, detail: rec.hookErrors.map(String).join('; ') });
316
+ continue;
317
+ }
318
+ if (rec.subtype === 'api_error' || rec.level === 'error') {
319
+ events.push({ kind: 'api_error', at: t, detail: textOf(rec.content) || rec.subtype || 'system error' });
320
+ }
321
+ continue;
322
+ }
323
+ if (type === 'user') {
324
+ const a = agentFor(rec);
325
+ const msg = rec.message || {};
326
+ const content = msg.content;
327
+ const blocks = Array.isArray(content) ? content : null;
328
+ const toolResults = blocks ? blocks.filter((b) => b && b.type === 'tool_result') : [];
329
+ if (toolResults.length) {
330
+ for (const tr of toolResults) {
331
+ const call = toolCallById.get(tr.tool_use_id);
332
+ const text = textOf(tr.content);
333
+ const structured = rec.toolUseResult;
334
+ if (call) {
335
+ call.end = t;
336
+ call.durationMs = (t != null && call.start != null) ? Math.max(0, t - call.start) : null;
337
+ call.status = tr.is_error ? 'error' : 'ok';
338
+ call.isError = !!tr.is_error;
339
+ call.resultText = text;
340
+ call.resultChars = text.length;
341
+ call.resultStructured = structured != null ? structured : null;
342
+ const imgs = Array.isArray(tr.content) ? tr.content.filter((b) => b && b.type === 'image') : [];
343
+ call.resultImages = imgs.length;
344
+ call.imageBytes = imgs.reduce((s, b) => s + Math.round(((b.source && b.source.data) || '').length * 0.75), 0);
345
+ if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = JSON.stringify(structured).length;
346
+ // stream-json puts is_error only in block; Claude Code also stores toolUseResult as an Error string
347
+ if (!tr.is_error && typeof structured === 'string' && /^Error/i.test(structured)) { call.status = 'error'; call.isError = true; }
348
+ } else {
349
+ toolCalls.push(mkOrphanResult(tr, rec, a.id, text, turns.length ? turns[turns.length - 1].index : null));
350
+ }
351
+ }
352
+ lastToolResultAt = t;
353
+ continue;
354
+ }
355
+ // Human / meta prompt
356
+ const text = textOf(content);
357
+ let kind = 'human';
358
+ if (rec.isCompactSummary) kind = 'compact';
359
+ else if (rec.isMeta) kind = 'meta';
360
+ else if (/^<(system-reminder|local-command|command-name|bash-input|bash-stdout)/.test(text.trim())) kind = 'meta';
361
+ ensureTurn(rec, kind, text);
362
+ continue;
363
+ }
364
+ if (type === 'assistant') {
365
+ const a = agentFor(rec);
366
+ const msg = rec.message || {};
367
+ const key = rec.requestId || msg.id || rec.uuid || ('seq' + rec._seq);
368
+ let req = requestByKey.get(key);
369
+ if (!req) {
370
+ let turn = currentTurn(a.id);
371
+ if (!turn) turn = ensureTurn(rec, 'implicit', '');
372
+ req = {
373
+ id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t,
374
+ usage: emptyUsage(), blocks: [], turnIndex: turn.index, contextTokens: 0, records: 0,
375
+ isApiError: !!rec.isApiErrorMessage, uuid: rec.uuid || null,
376
+ };
377
+ requestByKey.set(key, req);
378
+ requests.push(req);
379
+ turn.requestIds.push(req.id);
380
+ a.requestCount++;
381
+ if (msg.model) { pushModel(meta, msg.model); if (!a.model) a.model = msg.model; }
382
+ const u = usageFrom(msg.usage);
383
+ if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; addUsage(turn.usage, u); addUsage(a.usage, u); }
384
+ } else {
385
+ if (t != null) { if (req.start == null || t < req.start) req.start = t; if (req.end == null || t > req.end) req.end = t; }
386
+ // usage: if the first record lacked it and a later one has it, take it once
387
+ if (isEmptyUsage(req.usage)) {
388
+ const u = usageFrom(msg.usage);
389
+ if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; const turn = turns[req.turnIndex]; addUsage(turn.usage, u); addUsage(a.usage, u); }
390
+ }
391
+ }
392
+ req.records++;
393
+ if (msg.stop_reason) req.stopReason = msg.stop_reason;
394
+ if (rec.isApiErrorMessage) req.isApiError = true;
395
+ const content = Array.isArray(msg.content) ? msg.content : (msg.content != null ? [{ type: 'text', text: String(msg.content) }] : []);
396
+ const blockIndex = typeof rec.apiBlockIndex === 'number' ? rec.apiBlockIndex : req.blocks.length;
397
+ for (const b of content) {
398
+ if (!b || typeof b !== 'object') continue;
399
+ if (b.type === 'tool_use') {
400
+ const call = {
401
+ id: b.id || (key + ':' + blockIndex), agent: a.id, requestId: req.id, name: b.name || 'unknown', input: b.input || {},
402
+ start: t, end: null, durationMs: null, status: 'orphan', isError: false, resultText: '', resultChars: 0,
403
+ resultStructured: null, category: toolCategory(b.name), turnIndex: req.turnIndex, subagentId: null,
404
+ inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0,
405
+ };
406
+ toolCalls.push(call);
407
+ toolCallById.set(call.id, call);
408
+ turns[req.turnIndex].toolCallIds.push(call.id);
409
+ a.toolCount++;
410
+ req.blocks.push({ type: 'tool_use', name: call.name, id: call.id, index: blockIndex });
411
+ } else if (b.type === 'text') {
412
+ req.blocks.push({ type: 'text', text: b.text || '', index: blockIndex });
413
+ } else if (b.type === 'thinking' || b.type === 'redacted_thinking') {
414
+ req.blocks.push({ type: 'thinking', text: b.thinking || '', index: blockIndex });
415
+ } else {
416
+ req.blocks.push({ type: b.type || 'unknown', index: blockIndex });
417
+ }
418
+ }
419
+ if (a.id === 'main' && t != null) { lastAssistantEnd = t; if (msg.stop_reason === 'end_turn') lastAssistantEndTurn = t; }
420
+ continue;
421
+ }
422
+ // ignored: queue-operation, last-prompt, atis-latch, file-history-snapshot, progress, etc.
423
+ }
424
+
425
+ // Finalise
426
+ for (const req of requests) req.blocks.sort((x, y) => x.index - y.index);
427
+ for (const turn of turns) if (turn.end == null) turn.end = meta.end;
428
+ // Response timing. Assistant record timestamps are written when a block finishes streaming,
429
+ // so a request's "start" is really its first completed block. We measure from the moment the
430
+ // model had everything it needed (previous tool result in this turn, else the prompt) to the
431
+ // last block: responseMs. tokensPerSec = output / responseMs.
432
+ const callsByTurnIdx = new Map();
433
+ for (const c of toolCalls) { if (!callsByTurnIdx.has(c.turnIndex)) callsByTurnIdx.set(c.turnIndex, []); callsByTurnIdx.get(c.turnIndex).push(c); }
434
+ for (const req of requests) {
435
+ req.blocksSummary = req.blocks.map((b) => b.type === 'tool_use' ? b.name : b.type).join(',');
436
+ req.responseFrom = null; req.responseMs = null; req.tokensPerSec = null;
437
+ if (req.start == null || req.end == null) continue;
438
+ const turn = turns[req.turnIndex];
439
+ let from = turn && turn.start != null ? turn.start : req.start;
440
+ for (const c of (callsByTurnIdx.get(req.turnIndex) || [])) if (c.end != null && c.end <= req.start && c.end > from) from = c.end;
441
+ for (const other of requests) if (other !== req && other.agent === req.agent && other.turnIndex === req.turnIndex && other.end != null && other.end <= req.start && other.end > from) from = other.end;
442
+ req.responseFrom = from;
443
+ req.responseMs = Math.max(0, req.end - from);
444
+ req.tokensPerSec = req.responseMs > 0 ? req.usage.output / (req.responseMs / 1000) : null;
445
+ }
446
+ for (const ag of agents.values()) ag.modelTimeMs = requests.filter((r) => r.agent === ag.id).reduce((s, r) => s + (r.responseMs || 0), 0);
447
+
448
+ // Link subagents
449
+ const agentList = Array.from(agents.values());
450
+ for (const ag of agentList) {
451
+ if (ag.id === 'main') continue;
452
+ const m = subagentMeta[ag.id];
453
+ if (m) { ag.parentToolUseId = m.toolUseId || null; ag.description = m.description || null; ag.agentType = m.agentType || null; }
454
+ if (!ag.parentToolUseId) {
455
+ const parent = toolCalls.find((c) => c.category === 'agent' && c.agent === 'main' && ((c.resultText && c.resultText.includes(ag.id)) || (c.resultStructured && JSON.stringify(c.resultStructured).includes(ag.id))));
456
+ if (parent) ag.parentToolUseId = parent.id;
457
+ }
458
+ if (ag.parentToolUseId) {
459
+ const parent = toolCallById.get(ag.parentToolUseId);
460
+ if (parent) { parent.subagentId = ag.id; if (!ag.description && parent.input && parent.input.description) ag.description = parent.input.description; if (!ag.agentType && parent.input && parent.input.subagent_type) ag.agentType = parent.input.subagent_type; }
461
+ }
462
+ }
463
+
464
+ // Totals
465
+ const usage = emptyUsage();
466
+ for (const r of requests) addUsage(usage, r.usage);
467
+ const humanIdleMs = turns.reduce((s, tn) => s + (tn.idleBeforeMs || 0), 0);
468
+ const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : null;
469
+ const totals = {
470
+ usage, wallMs, humanIdleMs, activeMs: wallMs != null ? Math.max(0, wallMs - humanIdleMs) : null,
471
+ requests: requests.length, toolCalls: toolCalls.length, toolErrors: toolCalls.filter((c) => c.isError).length,
472
+ orphans: toolCalls.filter((c) => c.status === 'orphan').length,
473
+ turns: turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length,
474
+ toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
475
+ modelTimeMs: agents.get('main').modelTimeMs || 0,
476
+ cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
477
+ attachments,
478
+ };
479
+
480
+ return { version: VERSION, meta, requests, toolCalls, turns, agents: agentList, events, problems, totals };
481
+ }
482
+
483
+ function pushModel(meta, m) { if (m && !meta.models.includes(m)) meta.models.push(m); }
484
+
485
+ function mkOrphanResult(tr, rec, agentId, text, turnIndex) {
486
+ return {
487
+ id: tr.tool_use_id || ('result:' + rec._seq), agent: agentId, requestId: null, name: '(unmatched result)', input: {},
488
+ start: ts(rec), end: ts(rec), durationMs: 0, status: tr.is_error ? 'error' : 'ok', isError: !!tr.is_error, resultText: text,
489
+ resultChars: text.length, resultStructured: null, category: 'other', turnIndex, subagentId: null, inputChars: 0, unmatched: true,
490
+ };
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Diagnostics
495
+ // ---------------------------------------------------------------------------
496
+ function diagnose(trace, userOpts) {
497
+ const o = Object.assign({}, DEFAULTS, userOpts || {});
498
+ const findings = [];
499
+ const push = (f) => { findings.push(Object.assign({ evidence: {} }, f)); };
500
+ const calls = trace.toolCalls;
501
+ const reqs = trace.requests;
502
+
503
+ // retry-loop: same tool + identical input within a turn
504
+ const byTurnSig = new Map();
505
+ for (const c of calls) {
506
+ if (c.unmatched) continue;
507
+ const sig = c.turnIndex + '|' + c.agent + '|' + c.name + '|' + stableStringify(c.input);
508
+ if (!byTurnSig.has(sig)) byTurnSig.set(sig, []);
509
+ byTurnSig.get(sig).push(c);
510
+ }
511
+ for (const group of byTurnSig.values()) {
512
+ if (group.length >= o.retryLoopMin) {
513
+ const errs = group.filter((c) => c.isError).length;
514
+ push({
515
+ id: 'retry-loop', severity: errs >= 2 ? 'error' : 'warn',
516
+ title: `${group[0].name} called ${group.length}× with identical input`,
517
+ detail: errs ? `${errs} of them failed — the agent kept retrying the same call.` : 'Same call repeated in one turn; the result was probably already in context.',
518
+ evidence: { toolCallIds: group.map((c) => c.id), turnIndex: group[0].turnIndex }, metric: group.length,
519
+ });
520
+ }
521
+ }
522
+
523
+ // failed-tool: per tool error rate
524
+ const byTool = new Map();
525
+ for (const c of calls) { if (c.unmatched) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
526
+ for (const [name, list] of byTool) {
527
+ const errs = list.filter((c) => c.isError);
528
+ if (!errs.length) continue;
529
+ const rate = errs.length / list.length;
530
+ const bad = list.length >= o.failedToolMinCalls && rate >= o.failedToolRate;
531
+ push({
532
+ id: 'failed-tool', severity: bad ? 'error' : 'warn',
533
+ title: `${name} failed ${errs.length} of ${list.length} calls (${Math.round(rate * 100)}%)`,
534
+ detail: firstLine(errs[0].resultText) || 'Tool returned is_error.',
535
+ evidence: { toolCallIds: errs.map((c) => c.id) }, metric: rate,
536
+ });
537
+ }
538
+
539
+ // orphan-tool
540
+ const orphans = calls.filter((c) => c.status === 'orphan');
541
+ if (orphans.length) push({
542
+ id: 'orphan-tool', severity: 'warn', title: `${orphans.length} tool call${orphans.length > 1 ? 's' : ''} never got a result`,
543
+ detail: 'Session aborted, crashed, or was still running when the transcript was captured: ' + orphans.map((c) => c.name).slice(0, 5).join(', '),
544
+ evidence: { toolCallIds: orphans.map((c) => c.id) }, metric: orphans.length,
545
+ });
546
+
547
+ // exploration-run: consecutive read-category calls per agent
548
+ for (const ag of trace.agents) {
549
+ let run = [];
550
+ const flush = () => {
551
+ if (run.length >= o.explorationRunInfo) {
552
+ push({
553
+ id: 'exploration-run', severity: run.length >= o.explorationRunWarn ? 'warn' : 'info',
554
+ title: `${run.length} read-only calls in a row${ag.id !== 'main' ? ' (subagent)' : ''}`,
555
+ detail: 'Long exploration without writing or executing anything: ' + summariseNames(run),
556
+ evidence: { toolCallIds: run.map((c) => c.id), turnIndex: run[0].turnIndex }, metric: run.length,
557
+ });
558
+ }
559
+ run = [];
560
+ };
561
+ for (const c of calls) {
562
+ if (c.agent !== ag.id || c.unmatched) continue;
563
+ if (c.category === 'read') run.push(c); else if (c.category === 'write' || c.category === 'exec') flush(); // agent/user/mcp calls don't break a run
564
+ }
565
+ flush();
566
+ }
567
+
568
+ // oversized-result
569
+ for (const c of calls) {
570
+ if (c.resultChars >= o.oversizedResultWarn) push({
571
+ id: 'oversized-result', severity: c.resultChars >= o.oversizedResultError ? 'error' : 'warn',
572
+ title: `${c.name} returned ${fmtInt(c.resultChars)} chars`,
573
+ detail: 'Large tool results stay in context for the rest of the session. Consider head/limit, grep, or a subagent.',
574
+ evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
575
+ });
576
+ }
577
+
578
+ // image-heavy: screenshots and image reads stay in context as tokens for the rest of the session
579
+ const imgCalls = calls.filter((c) => c.imageBytes > 0);
580
+ const imgBytes = imgCalls.reduce((s, c) => s + c.imageBytes, 0);
581
+ if (imgBytes >= o.imageHeavyInfoBytes) push({
582
+ id: 'image-heavy', severity: imgBytes >= o.imageHeavyWarnBytes ? 'warn' : 'info',
583
+ title: `${imgCalls.reduce((s, c) => s + c.resultImages, 0)} image${imgCalls.length > 1 ? 's' : ''} (${(imgBytes / 1048576).toFixed(1)} MB) returned by tools`,
584
+ detail: 'Screenshots and image reads are billed as tokens on every later request. Crop, downscale, or read fewer of them: ' + summariseNames(imgCalls),
585
+ evidence: { toolCallIds: imgCalls.map((c) => c.id) }, metric: imgBytes,
586
+ });
587
+
588
+ // context-bloat
589
+ let crossed = null; let peak = null;
590
+ for (const r of reqs) {
591
+ if (r.contextTokens >= o.contextBloatWarn && !crossed) crossed = r;
592
+ if (!peak || r.contextTokens > peak.contextTokens) peak = r;
593
+ }
594
+ if (crossed) push({
595
+ id: 'context-bloat', severity: peak.contextTokens >= o.contextBloatError ? 'error' : 'warn',
596
+ title: `Context reached ${fmtInt(peak.contextTokens)} tokens`,
597
+ detail: `First crossed ${fmtInt(o.contextBloatWarn)} at request #${reqs.indexOf(crossed) + 1} (turn ${crossed.turnIndex + 1}). Every later request pays for that prefix.`,
598
+ evidence: { requestIds: [crossed.id, peak.id], turnIndex: crossed.turnIndex }, metric: peak.contextTokens,
599
+ });
600
+
601
+ // cache-churn
602
+ const firstReqOfAgent = new Set();
603
+ for (const r of reqs) {
604
+ if (!firstReqOfAgent.has(r.agent)) { firstReqOfAgent.add(r.agent); continue; }
605
+ if (r.usage.cacheWrite >= o.cacheChurnMin) push({
606
+ id: 'cache-churn', severity: 'info', title: `${fmtInt(r.usage.cacheWrite)} tokens re-cached at request #${reqs.indexOf(r) + 1}`,
607
+ detail: 'A large cache write mid-session means the cached prefix was invalidated (system prompt, tool list or early messages changed).',
608
+ evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.usage.cacheWrite,
609
+ });
610
+ }
611
+
612
+ // low-cache-hit
613
+ if (reqs.length >= o.lowCacheHitMinRequests && trace.totals.cacheHitRatio != null && trace.totals.cacheHitRatio < o.lowCacheHitRatio) push({
614
+ id: 'low-cache-hit', severity: 'info', title: `Cache hit ratio ${Math.round(trace.totals.cacheHitRatio * 100)}%`,
615
+ detail: 'Less than half of input tokens were served from cache. Cached reads cost ~10% of uncached input.',
616
+ evidence: {}, metric: trace.totals.cacheHitRatio,
617
+ });
618
+
619
+ // slow-tool
620
+ for (const c of calls) {
621
+ if (c.durationMs != null && c.durationMs >= o.slowToolInfoMs) push({
622
+ id: 'slow-tool', severity: c.durationMs >= o.slowToolWarnMs ? 'warn' : 'info',
623
+ title: `${c.name} took ${fmtDur(c.durationMs)}`, detail: c.category === 'agent' ? 'Subagent run time.' : c.category === 'user' ? 'Waiting on the human.' : 'Long-running tool call.',
624
+ evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.durationMs,
625
+ });
626
+ }
627
+
628
+ // slow-model: a long response that produced little — API latency, rate limiting or a stall,
629
+ // as opposed to a long response that was simply writing a lot (reported separately as info).
630
+ if (trace.meta.hasTimestamps) {
631
+ for (const r of reqs) {
632
+ if (r.responseMs == null || r.responseMs < o.slowModelMs) continue;
633
+ const n = reqs.indexOf(r) + 1;
634
+ if (r.usage.output < o.slowModelMaxOutput) push({
635
+ id: 'slow-model', severity: 'warn', title: `${fmtDur(r.responseMs)} for a ${fmtInt(r.usage.output)}-token response`,
636
+ detail: `Request #${n} (${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s): API latency, rate limiting or a stalled stream.`,
637
+ evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
638
+ });
639
+ else push({
640
+ id: 'long-generation', severity: 'info', title: `${fmtDur(r.responseMs)} writing ${fmtInt(r.usage.output)} tokens`,
641
+ detail: `Request #${n} at ${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s (${r.blocksSummary}). Big single outputs are normal for file writes; a stall would show as low tok/s.`,
642
+ evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
643
+ });
644
+ }
645
+ }
646
+
647
+ // max-tokens / api-error
648
+ for (const r of reqs) {
649
+ if (r.stopReason === 'max_tokens') push({ id: 'max-tokens', severity: 'warn', title: `Response truncated at request #${reqs.indexOf(r) + 1}`, detail: 'stop_reason was max_tokens; output was cut off.', evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.usage.output });
650
+ if (r.isApiError) push({ id: 'api-error', severity: 'error', title: `API error at request #${reqs.indexOf(r) + 1}`, detail: firstLine(r.blocks.filter((b) => b.type === 'text').map((b) => b.text).join(' ')) || 'Assistant record flagged isApiErrorMessage.', evidence: { requestIds: [r.id], turnIndex: r.turnIndex } });
651
+ }
652
+ for (const e of trace.events) {
653
+ if (e.kind === 'api_error') push({ id: 'api-error', severity: 'error', title: 'API error', detail: firstLine(e.detail), evidence: { at: e.at } });
654
+ if (e.kind === 'hook_error') push({ id: 'hook-error', severity: 'warn', title: 'Hook reported errors', detail: firstLine(e.detail), evidence: { at: e.at } });
655
+ if (e.kind === 'compact') push({ id: 'compaction', severity: 'info', title: 'Context was compacted', detail: e.detail, evidence: { at: e.at } });
656
+ }
657
+
658
+ // thinking-heavy
659
+ const u = trace.totals.usage;
660
+ if (u.output >= o.thinkingHeavyMinOutput && u.thinking / u.output > o.thinkingHeavyShare) push({
661
+ id: 'thinking-heavy', severity: 'info', title: `${Math.round(u.thinking / u.output * 100)}% of output tokens were thinking`,
662
+ detail: 'Fine for hard problems; for routine tool loops a lower effort setting would be cheaper.', evidence: {}, metric: u.thinking / u.output,
663
+ });
664
+
665
+ // subagent-share
666
+ const subTokens = trace.agents.filter((a) => a.id !== 'main').reduce((s, a) => s + a.usage.output + a.usage.input + a.usage.cacheRead + a.usage.cacheWrite, 0);
667
+ const allTokens = u.output + u.input + u.cacheRead + u.cacheWrite;
668
+ if (allTokens && subTokens / allTokens >= o.subagentShare) push({
669
+ id: 'subagent-share', severity: 'info', title: `Subagents used ${Math.round(subTokens / allTokens * 100)}% of all tokens`,
670
+ detail: 'Most of the spend happened in delegated work.', evidence: {}, metric: subTokens / allTokens,
671
+ });
672
+
673
+ // long-turn
674
+ for (const tn of trace.turns) if (tn.toolCallIds.length >= o.longTurnTools) push({
675
+ id: 'long-turn', severity: 'info', title: `Turn ${tn.index + 1} made ${tn.toolCallIds.length} tool calls`,
676
+ detail: 'A very long autonomous stretch. Worth a skim for wasted work.', evidence: { turnIndex: tn.index, toolCallIds: tn.toolCallIds.slice() }, metric: tn.toolCallIds.length,
677
+ });
678
+
679
+ const order = { error: 0, warn: 1, info: 2 };
680
+ findings.sort((a, b) => order[a.severity] - order[b.severity] || (b.metric || 0) - (a.metric || 0));
681
+ return findings;
682
+ }
683
+
684
+ function summariseNames(list) {
685
+ const counts = {};
686
+ for (const c of list) counts[c.name] = (counts[c.name] || 0) + 1;
687
+ return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([n, k]) => `${n}×${k}`).join(', ');
688
+ }
689
+ function firstLine(s) { if (!s) return ''; const l = String(s).split('\n').find((x) => x.trim()); return l ? l.trim().slice(0, 200) : ''; }
690
+ function fmtInt(n) { return (n == null || !Number.isFinite(n)) ? '—' : Math.round(n).toLocaleString('en-US'); }
691
+ function fmtDur(ms) {
692
+ if (ms == null || !Number.isFinite(ms)) return '—';
693
+ if (ms < 1000) return Math.round(ms) + ' ms';
694
+ const s = ms / 1000;
695
+ if (s < 60) return (s < 10 ? s.toFixed(1) : Math.round(s)) + ' s';
696
+ const m = Math.floor(s / 60), r = Math.round(s % 60);
697
+ if (m < 60) return m + 'm ' + (r < 10 ? '0' : '') + r + 's';
698
+ const h = Math.floor(m / 60);
699
+ return h + 'h ' + (m % 60) + 'm';
700
+ }
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // Cost
704
+ // ---------------------------------------------------------------------------
705
+ function rateFor(model, rates) {
706
+ rates = rates || RATES;
707
+ if (!model) return null;
708
+ if (rates[model]) return rates[model];
709
+ let best = null;
710
+ for (const k of Object.keys(rates)) if (model.startsWith(k) && (!best || k.length > best.length)) best = k;
711
+ return best ? rates[best] : null;
712
+ }
713
+ function requestCost(u, rate) {
714
+ if (!rate) return null;
715
+ const w5 = u.cacheWrite5m || 0, w1 = u.cacheWrite1h || 0;
716
+ const write = (w5 || w1) ? (w5 * rate.w5m + w1 * rate.w1h) : (u.cacheWrite * rate.w5m);
717
+ return (u.input * rate.in + u.cacheRead * rate.read + write + u.output * rate.out) / 1e6;
718
+ }
719
+ function estimateCost(trace, rates) {
720
+ rates = rates || RATES;
721
+ let total = 0; let known = 0;
722
+ const byRequest = []; const byModel = {}; const byAgent = {}; const unknownModels = new Set();
723
+ for (const r of trace.requests) {
724
+ const rate = rateFor(r.model, rates);
725
+ const c = requestCost(r.usage, rate);
726
+ byRequest.push(c);
727
+ if (c == null) { if (r.model) unknownModels.add(r.model); continue; }
728
+ total += c; known++;
729
+ byModel[r.model] = (byModel[r.model] || 0) + c;
730
+ byAgent[r.agent] = (byAgent[r.agent] || 0) + c;
731
+ }
732
+ const complete = known === trace.requests.length;
733
+ return {
734
+ total: known ? total : null, complete, byRequest, byModel, byAgent, unknownModels: Array.from(unknownModels),
735
+ reported: trace.meta.reportedCost, source: trace.meta.reportedCost != null ? 'reported' : (known ? 'estimated' : 'unknown'),
736
+ };
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Redaction — keep structure, timestamps, usage, tool names; blank every string.
741
+ // ---------------------------------------------------------------------------
742
+ const KEEP_KEYS = new Set(['type', 'subtype', 'role', 'name', 'id', 'tool_use_id', 'uuid', 'parentUuid', 'requestId', 'sessionId', 'session_id', 'agentId', 'timestamp', 'model', 'stop_reason', 'version', 'entrypoint', 'operation', 'trigger', 'status', 'level', 'userType', 'apiBlockIndex', 'isSidechain', 'isMeta', 'isCompactSummary', 'isApiErrorMessage', 'is_error', 'effort', 'permissionMode', 'promptSource', 'sourceToolAssistantUUID', 'promptId', 'leafUuid', 'toolUseID', 'agentType', 'toolUseId', 'spawnDepth']);
743
+ function redactValue(v, key, depth) {
744
+ if (v == null) return v;
745
+ if (typeof v === 'string') {
746
+ if (KEEP_KEYS.has(key)) return v;
747
+ return '«' + v.length + ' chars»';
748
+ }
749
+ if (typeof v !== 'object') return v; // numbers, booleans (usage stays)
750
+ if (Array.isArray(v)) return v.map((x) => redactValue(x, key, depth + 1));
751
+ const out = {};
752
+ for (const k of Object.keys(v)) {
753
+ if (k === 'input' && key === undefined) { out[k] = redactValue(v[k], k, depth + 1); continue; }
754
+ out[k] = redactValue(v[k], k, depth + 1);
755
+ }
756
+ return out;
757
+ }
758
+ function redact(records) {
759
+ return records.map((r) => {
760
+ const c = redactValue(r, undefined, 0);
761
+ delete c._line; delete c._file; delete c._seq;
762
+ return c;
763
+ });
764
+ }
765
+ function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
766
+
767
+ return { VERSION, RATES, DEFAULTS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, stableStringify };
768
+ });