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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/bin/glassbox.mjs +3 -0
- package/dist/glassbox.html +1653 -0
- package/package.json +54 -0
- package/src/cli.mjs +246 -0
- package/src/trace-core.js +768 -0
- package/src/viewer.html +875 -0
|
@@ -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
|
+
});
|