glassbox-trace 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -26
- package/dist/glassbox.html +592 -67
- package/package.json +57 -54
- package/src/cli.mjs +217 -31
- package/src/tail.mjs +141 -0
- package/src/trace-core.js +426 -50
- package/src/viewer.html +166 -17
package/dist/glassbox.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Glassbox</title>
|
|
7
|
-
<meta name="description" content="
|
|
7
|
+
<meta name="description" content="Glassbox tells you what went wrong in a Claude Code or Agent SDK session. Drop a session .jsonl; get a timeline, token and cost burn, auto-diagnostics, session compare and live tail. Nothing leaves your browser.">
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2712%27 fill=%27%23161C24%27/%3E%3Crect x=%2712%27 y=%2728%27 width=%2740%27 height=%278%27 rx=%272%27 fill=%27%23FF8A4D%27/%3E%3Crect x=%2712%27 y=%2716%27 width=%2724%27 height=%278%27 rx=%272%27 fill=%27%237DB0F0%27/%3E%3Crect x=%2724%27 y=%2740%27 width=%2728%27 height=%278%27 rx=%272%27 fill=%27%234FD0BB%27/%3E%3C/svg%3E">
|
|
9
9
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
10
10
|
<style>
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
--err:#C4342A;--warn:#8A5F00;--info:#2A63B8;--ok:#177A6B;
|
|
18
18
|
--err-soft:rgba(196,52,42,.12);--warn-soft:rgba(138,95,0,.14);--info-soft:rgba(42,99,184,.12);
|
|
19
19
|
--focus:#B8420A;
|
|
20
|
+
--who-a:#2A63B8;--who-b:#5B3FB8;
|
|
20
21
|
--shadow:0 10px 30px rgba(22,28,36,.14);
|
|
21
22
|
--sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
22
23
|
--cond:"IBM Plex Sans Condensed","Arial Narrow",var(--sans);
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
--err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
|
|
34
35
|
--err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
|
|
35
36
|
--focus:#FF8A4D;
|
|
37
|
+
--who-a:#7DB0F0;--who-b:#B39CF5;
|
|
36
38
|
--shadow:0 10px 30px rgba(0,0,0,.5);
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -45,6 +47,7 @@
|
|
|
45
47
|
--err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
|
|
46
48
|
--err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
|
|
47
49
|
--focus:#FF8A4D;
|
|
50
|
+
--who-a:#7DB0F0;--who-b:#B39CF5;
|
|
48
51
|
--shadow:0 10px 30px rgba(0,0,0,.5);
|
|
49
52
|
}
|
|
50
53
|
:root[data-density="compact"]{--fs:12.5px;--pad:8px;--rowh:15px}
|
|
@@ -81,6 +84,33 @@ a{color:var(--accent)}
|
|
|
81
84
|
.banner p{margin:0;flex:1}
|
|
82
85
|
.banner code{font-family:var(--mono);font-size:12.5px}
|
|
83
86
|
|
|
87
|
+
/* live pill + compare */
|
|
88
|
+
.live{display:inline-flex;align-items:center;gap:6px;font:500 11px/1 var(--cond);letter-spacing:.08em;text-transform:uppercase;color:var(--ok);border:1px solid var(--ok);border-radius:999px;padding:4px 9px;margin-left:8px}
|
|
89
|
+
.live i{width:7px;height:7px;border-radius:50%;background:var(--ok);animation:pulse 1.6s ease-in-out infinite}
|
|
90
|
+
.live.off{color:var(--warn);border-color:var(--warn)}.live.off i{background:var(--warn);animation:none}
|
|
91
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
92
|
+
@media (prefers-reduced-motion:reduce){.live i{animation:none}}
|
|
93
|
+
.cmp{padding:var(--pad)}
|
|
94
|
+
.cmp .verdict{display:flex;gap:18px;flex-wrap:wrap;font-size:13px;margin:0 0 10px;color:var(--muted)}
|
|
95
|
+
.cmp .verdict b{color:var(--ink)}
|
|
96
|
+
.cmp .who{display:inline-block;padding:1px 7px;border-radius:999px;font:500 11px var(--cond);letter-spacing:.06em;text-transform:uppercase;background:var(--panel-2);border:1px solid var(--line);color:var(--ink-2)}
|
|
97
|
+
.cmp .who.a{border-color:var(--who-a);color:var(--who-a)}.cmp .who.b{border-color:var(--who-b);color:var(--who-b)}
|
|
98
|
+
.cmp .grid{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:16px}
|
|
99
|
+
@media (max-width:900px){.cmp .grid{grid-template-columns:1fr}}
|
|
100
|
+
.cmp table{font-size:13px}
|
|
101
|
+
.cmp td.d{min-width:140px}
|
|
102
|
+
.cmp .dbar{display:flex;align-items:center;gap:8px}
|
|
103
|
+
.cmp .dbar .track{position:relative;flex:1;height:8px;background:var(--panel-2);border-radius:4px;overflow:hidden;border:1px solid var(--line-2)}
|
|
104
|
+
.cmp .dbar .fill{position:absolute;top:0;bottom:0;background:var(--faint)}
|
|
105
|
+
.cmp .dbar .fill.good{background:var(--ok)}.cmp .dbar .fill.bad{background:var(--err)}
|
|
106
|
+
.cmp .dbar .ch{font-family:var(--mono);font-size:12px;min-width:56px;text-align:right;color:var(--muted)}
|
|
107
|
+
.cmp .dbar .ch.good{color:var(--ok)}.cmp .dbar .ch.bad{color:var(--err)}
|
|
108
|
+
.cmp h3{font:600 11px var(--cond);letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:14px 0 6px}
|
|
109
|
+
.cmp .fl{margin:0;padding:0;list-style:none;font-size:13px}
|
|
110
|
+
.cmp .fl li{padding:4px 0;border-top:1px solid var(--line-2)}
|
|
111
|
+
.cmp .fl .id{font:11px var(--mono);color:var(--faint);margin-left:6px}
|
|
112
|
+
.cmp .empty{padding:8px 0}
|
|
113
|
+
|
|
84
114
|
/* session line */
|
|
85
115
|
.session{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline;padding:10px 0 0;color:var(--muted);font-size:13px}
|
|
86
116
|
.session .title{color:var(--ink);font-weight:500;font-size:15px}
|
|
@@ -235,7 +265,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
235
265
|
<body>
|
|
236
266
|
<div class="app">
|
|
237
267
|
<header class="mast">
|
|
238
|
-
<div class="brand"><h1>Glass<span>box</span></h1><span class="tag">
|
|
268
|
+
<div class="brand"><h1>Glass<span>box</span></h1><span class="tag">what went wrong in your Claude Code session</span><span class="live" id="live" hidden role="status" aria-live="polite"><i aria-hidden="true"></i><span id="live-txt">LIVE</span></span></div>
|
|
239
269
|
<label class="drop" id="drop" tabindex="0" aria-label="Drop a session .jsonl file or folder here, or press Enter to choose files">
|
|
240
270
|
<input type="file" id="file" multiple accept=".jsonl,.json,.txt" class="sr-only">
|
|
241
271
|
<span class="hint"><b>Drop a session .jsonl</b> (or the whole session folder) — parsed in your browser, never uploaded.</span>
|
|
@@ -245,6 +275,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
245
275
|
<button class="btn" id="open-folder" hidden title="Browse ~/.claude/projects and pick a session">Open folder…</button>
|
|
246
276
|
<button class="btn" id="recent" hidden title="Sessions from the folder you opened before">Recent</button>
|
|
247
277
|
<button class="btn" id="demo">Load demo</button>
|
|
278
|
+
<button class="btn" id="compare" title="Load a second session and compare it with this one">Compare…</button>
|
|
279
|
+
<input type="file" id="file-cmp" multiple accept=".jsonl,.json,.txt" class="sr-only" aria-label="Choose the session to compare with">
|
|
248
280
|
<button class="btn" id="share" aria-pressed="false" title="Blank every prompt, input and result so the trace can be shared">Share mode</button>
|
|
249
281
|
<button class="btn" id="export" disabled title="Download a structure-only copy of the transcript">Export redacted</button>
|
|
250
282
|
<button class="btn" id="rates" title="Edit the per-model rate card">Rates</button>
|
|
@@ -261,9 +293,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
261
293
|
<div id="problems" class="problems" hidden role="alert"></div>
|
|
262
294
|
<dl class="strip" id="strip"></dl>
|
|
263
295
|
|
|
296
|
+
<section class="sec" id="cmp-sec" hidden aria-labelledby="h-cmp">
|
|
297
|
+
<div class="sec-h"><h2 id="h-cmp">Compare</h2><div class="tools"><span class="aux" id="cmp-aux"></span><button class="btn sm" id="cmp-swap" title="Put the other session on the timeline">Swap</button><button class="btn sm" id="cmp-copy">Copy as Markdown</button><button class="btn sm" id="cmp-close" aria-label="Close compare">Close</button></div></div>
|
|
298
|
+
<div class="panel cmp" id="cmp"></div>
|
|
299
|
+
</section>
|
|
300
|
+
|
|
264
301
|
<section class="sec" aria-labelledby="h-tl">
|
|
265
302
|
<div class="sec-h"><h2 id="h-tl">Timeline</h2>
|
|
266
303
|
<div class="tools">
|
|
304
|
+
<button class="btn" id="follow" hidden aria-pressed="true" title="Keep the right edge of the timeline on now">Follow</button>
|
|
267
305
|
<div class="search"><label class="sr-only" for="q">Search prompts, tool inputs and results</label><input id="q" type="search" placeholder="Search…"><span class="n" id="q-n" aria-live="polite"></span></div>
|
|
268
306
|
<label class="sr-only" for="fit">Fit to turn</label><select id="fit" class="btn"></select>
|
|
269
307
|
<button class="btn" id="z-out" aria-label="Zoom out">−</button><button class="btn" id="z-in" aria-label="Zoom in">+</button><button class="btn" id="z-reset" aria-label="Reset zoom">⟲</button>
|
|
@@ -322,6 +360,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
322
360
|
<div class="db"><p class="note" style="margin:0 0 8px">The clipboard isn't available here — the text is selected below, press <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>C</kbd>.</p><label class="sr-only" for="copy-text">Text to copy</label><textarea id="copy-text" style="width:100%;height:240px;font:12px/1.45 var(--mono);background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:8px"></textarea></div>
|
|
323
361
|
</dialog>
|
|
324
362
|
|
|
363
|
+
<dialog id="cmp-dlg" aria-labelledby="cmp-h">
|
|
364
|
+
<div class="dh"><h3 id="cmp-h">Compare with another session</h3><button class="x" id="cmp-dlg-close" aria-label="Close">×</button></div>
|
|
365
|
+
<div class="db" style="font-size:13.5px;line-height:1.55">
|
|
366
|
+
<p style="margin:0 0 12px">Same task, two sessions: Glassbox lines up time, tokens, cost, tool use and findings side by side. The session already loaded stays on the timeline as <b>A</b>; the one you pick becomes <b>B</b>.</p>
|
|
367
|
+
<div class="controls"><button class="btn primary" id="cmp-pick">Choose files…</button><button class="btn" id="cmp-folder" hidden>From folder…</button><button class="btn" id="cmp-demo">Use the demo</button></div>
|
|
368
|
+
<p class="note" style="margin:12px 0 0">Or drop the second session's <code class="mono">.jsonl</code> anywhere on the page while this dialog is open. From the CLI: <code class="mono">glassbox compare A B --out compare.html</code>.</p>
|
|
369
|
+
</div>
|
|
370
|
+
</dialog>
|
|
371
|
+
|
|
325
372
|
<dialog id="help-dlg" aria-labelledby="help-h">
|
|
326
373
|
<div class="dh"><h3 id="help-h">How to read Glassbox</h3><button class="x" id="help-close" aria-label="Close">×</button></div>
|
|
327
374
|
<div class="db" style="font-size:13.5px;line-height:1.55" tabindex="0">
|
|
@@ -329,6 +376,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
329
376
|
<p><b>Context & cost.</b> One bar per API request: the size of the prompt the model saw, split into cached reads (light), cache writes (mid) and uncached input (dark). The line is cumulative estimated cost.</p>
|
|
330
377
|
<p><b>Findings.</b> Mechanical rules, no AI: identical calls repeated in a turn, tool error rates, results over 20k characters, context over 120k tokens, mid-session cache invalidation, responses that took over a minute and produced little, truncated outputs, hook and API errors, compaction events. Select one to highlight its evidence on the timeline; copy one or all as Markdown to paste into an issue or back into the agent.</p>
|
|
331
378
|
<p><b>Tokens.</b> Assistant messages are stored one record per content block with the usage repeated on each; Glassbox counts usage once per API request. Thinking tokens are part of output tokens, shown as a share, never double counted. Cost is an estimate from the rate card unless the file carries a reported <code class="mono">total_cost_usd</code>.</p>
|
|
379
|
+
<p><b>Compare.</b> Load a second session (<b>Compare…</b>) and Glassbox lines the two up: every metric with a change bar, tool use side by side, and which findings appear in only one of them. <b>Swap</b> puts the other session on the timeline.</p>
|
|
380
|
+
<p><b>Live.</b> <code class="mono">glassbox watch</code> serves this page from your own machine and streams the transcript as Claude Code writes it. <b>Follow</b> keeps the right edge on now; pan or zoom and it lets go.</p>
|
|
332
381
|
<p><b>Share mode.</b> Blanks every prompt, tool input, result and assistant text in the view; <b>Export redacted</b> downloads a structure-only <code class="mono">.jsonl</code> that keeps timestamps, usage and tool names — safe to post publicly.</p>
|
|
333
382
|
<p><b>Keyboard.</b></p>
|
|
334
383
|
<div class="keys">
|
|
@@ -353,7 +402,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
353
402
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
354
403
|
'use strict';
|
|
355
404
|
|
|
356
|
-
const VERSION = '0.
|
|
405
|
+
const VERSION = '0.5.0';
|
|
357
406
|
|
|
358
407
|
// ---------------------------------------------------------------------------
|
|
359
408
|
// Rate card (USD per million tokens). Prefix-matched against model ids so dated
|
|
@@ -401,6 +450,13 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
401
450
|
longTurnTools: 30,
|
|
402
451
|
failedToolRate: 0.5,
|
|
403
452
|
failedToolMinCalls: 3,
|
|
453
|
+
failedToolWarnMinErrors: 2,
|
|
454
|
+
failedToolWarnRate: 0.2,
|
|
455
|
+
cacheMissMinShare: 0.05,
|
|
456
|
+
longGenerationMaxTokPerSec: 15,
|
|
457
|
+
duplicateReadAgents: 3,
|
|
458
|
+
duplicateReadWarnChars: 100000,
|
|
459
|
+
groupRepeatsMin: 3, // slow-tool / oversized-result: this many from one tool become one finding
|
|
404
460
|
};
|
|
405
461
|
|
|
406
462
|
// ---------------------------------------------------------------------------
|
|
@@ -410,18 +466,31 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
410
466
|
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
411
467
|
const EXEC_TOOLS = new Set(['Bash', 'BashOutput', 'KillShell']);
|
|
412
468
|
const AGENT_TOOLS = new Set(['Agent', 'Task', 'Workflow']);
|
|
413
|
-
const USER_TOOLS = new Set(['AskUserQuestion']);
|
|
469
|
+
const USER_TOOLS = new Set(['AskUserQuestion', 'SendUserMessage', 'SendUserFile', 'ExitPlanMode', 'EnterPlanMode']);
|
|
470
|
+
// Cowork: Artifact publishes a page (a write), Skill loads instructions (a read); Task* are bookkeeping (other).
|
|
471
|
+
const COWORK_READ = new Set(['Skill', 'ListSkills', 'SearchSkills', 'TaskList', 'TaskGet']);
|
|
472
|
+
const COWORK_WRITE = new Set(['Artifact']);
|
|
473
|
+
// Blocking reads of a background task's output: the duration is the task's run time, and re-issuing the
|
|
474
|
+
// same call after a "still running" timeout is polling, not a retry.
|
|
475
|
+
const WAIT_TOOLS = new Set(['TaskOutput', 'AgentOutputTool']);
|
|
476
|
+
const isWait = (c) => WAIT_TOOLS.has(c.name) && !(c.input && c.input.block === false);
|
|
477
|
+
const waitTarget = (c) => { const i = c.input || {}; return String(i.task_id || i.agentId || i.bash_id || ''); };
|
|
478
|
+
const hasRange = (c) => !!(c.input && (c.input.limit != null || c.input.offset != null || c.input.head_limit != null));
|
|
479
|
+
// MCP leaf names are verb_noun or noun_verb ("memory_read", "device_list_dir", "send_message"): match the verb as a word.
|
|
480
|
+
const MCP_READ = /(^|_)(get|list|search|read|find|fetch|query|describe|show|stage|screenshot|context|status|info|check)(_|$)/i;
|
|
481
|
+
const MCP_WRITE = /(^|_)(write|set|put|create|update|delete|remove|commit|send|reply|forward|upload|str_replace|append|label|move|rename|save)(_|$)/i;
|
|
414
482
|
|
|
415
483
|
function toolCategory(name) {
|
|
416
484
|
if (!name) return 'other';
|
|
417
|
-
if (READ_TOOLS.has(name)) return 'read';
|
|
418
|
-
if (WRITE_TOOLS.has(name)) return 'write';
|
|
485
|
+
if (READ_TOOLS.has(name) || COWORK_READ.has(name)) return 'read';
|
|
486
|
+
if (WRITE_TOOLS.has(name) || COWORK_WRITE.has(name)) return 'write';
|
|
419
487
|
if (EXEC_TOOLS.has(name) || /device_bash$/.test(name)) return 'exec';
|
|
420
488
|
if (AGENT_TOOLS.has(name)) return 'agent';
|
|
421
489
|
if (USER_TOOLS.has(name)) return 'user';
|
|
422
490
|
if (name.startsWith('mcp__')) {
|
|
423
491
|
const leaf = name.split('__').pop() || '';
|
|
424
|
-
if (
|
|
492
|
+
if (MCP_READ.test(leaf)) return 'read';
|
|
493
|
+
if (MCP_WRITE.test(leaf)) return 'write';
|
|
425
494
|
return 'mcp';
|
|
426
495
|
}
|
|
427
496
|
return 'other';
|
|
@@ -487,6 +556,26 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
487
556
|
return !u.input && !u.cacheRead && !u.cacheWrite && !u.output;
|
|
488
557
|
}
|
|
489
558
|
|
|
559
|
+
// A tool call that never ran because the human or a permission rule stopped it. Claude Code
|
|
560
|
+
// records toolDenialKind (user-rejected, permission-rule, automode-blocked, interrupted); older
|
|
561
|
+
// transcripts only have the result text. A shell's own "Permission denied" is a real failure.
|
|
562
|
+
function denialKind(rec, text) {
|
|
563
|
+
if (rec && rec.toolDenialKind) return String(rec.toolDenialKind);
|
|
564
|
+
const t = String(text || '').trim();
|
|
565
|
+
if (/^\[Request interrupted by user/.test(t)) return 'interrupted';
|
|
566
|
+
if (/^The user doesn't want to proceed with this tool use|^User rejected tool use|^Denied by voice/.test(t)) return 'user-rejected';
|
|
567
|
+
if (/^Permission (for this action was|to use \S+ has been) denied/.test(t)) return 'permission-rule';
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// One API response is written as several records (one per content block). Subagent transcripts
|
|
572
|
+
// rewrite output_tokens as the stream progresses, so the first record can be far below the final
|
|
573
|
+
// count; the other fields repeat. Keep the largest value seen for each field.
|
|
574
|
+
function maxUsage(a, b) {
|
|
575
|
+
for (const k of Object.keys(a)) if ((b[k] || 0) > a[k]) a[k] = b[k];
|
|
576
|
+
return a;
|
|
577
|
+
}
|
|
578
|
+
|
|
490
579
|
// ---------------------------------------------------------------------------
|
|
491
580
|
// Line parsing
|
|
492
581
|
// ---------------------------------------------------------------------------
|
|
@@ -577,7 +666,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
577
666
|
const meta = {
|
|
578
667
|
sessionId: null, version: null, cwd: null, gitBranch: null, entrypoint: null, title: null,
|
|
579
668
|
models: [], start: null, end: null, hasTimestamps, files: fileMeta, format: 'claude-code',
|
|
580
|
-
reportedCost: null, reportedUsage: null, recordCounts: {},
|
|
669
|
+
reportedCost: null, reportedUsage: null, reportedTurns: null, reportedDurationMs: null, recordCounts: {},
|
|
581
670
|
};
|
|
582
671
|
|
|
583
672
|
const agents = new Map(); // id -> agent
|
|
@@ -587,6 +676,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
587
676
|
agents.set(id, {
|
|
588
677
|
id, type: id === 'main' ? 'main' : 'subagent', description: null, parentToolUseId: null,
|
|
589
678
|
start: null, end: null, usage: emptyUsage(), requestCount: 0, toolCount: 0, model: null,
|
|
679
|
+
workflowRun: (String(rec._file || '').match(/workflows[\\/](wf_[^\\/]+)[\\/]/) || [])[1] || null,
|
|
590
680
|
});
|
|
591
681
|
}
|
|
592
682
|
const a = agents.get(id);
|
|
@@ -637,13 +727,17 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
637
727
|
if (!meta.gitBranch && rec.gitBranch) meta.gitBranch = rec.gitBranch;
|
|
638
728
|
if (!meta.entrypoint && rec.entrypoint) meta.entrypoint = rec.entrypoint;
|
|
639
729
|
const t = ts(rec);
|
|
640
|
-
|
|
730
|
+
// Only conversation records bound the session: bookkeeping records (frame-link, file-history…)
|
|
731
|
+
// can be written days later and would stretch the wall clock.
|
|
732
|
+
if (t != null && (type === 'user' || type === 'assistant' || type === 'system')) { if (meta.start == null || t < meta.start) meta.start = t; if (meta.end == null || t > meta.end) meta.end = t; }
|
|
641
733
|
|
|
642
734
|
if (type === 'summary') { if (rec.summary && !meta.title) meta.title = rec.summary; events.push({ kind: 'summary', at: t, detail: rec.summary }); continue; }
|
|
643
735
|
if (type === 'attachment') { attachments.count++; attachments.chars += JSON.stringify(rec.attachment || '').length; continue; }
|
|
644
736
|
if (type === 'result') {
|
|
645
737
|
meta.format = 'stream-json';
|
|
646
738
|
if (typeof rec.total_cost_usd === 'number') meta.reportedCost = rec.total_cost_usd;
|
|
739
|
+
if (typeof rec.num_turns === 'number') meta.reportedTurns = rec.num_turns;
|
|
740
|
+
if (typeof rec.duration_ms === 'number') meta.reportedDurationMs = rec.duration_ms;
|
|
647
741
|
if (rec.usage) meta.reportedUsage = usageFrom(rec.usage);
|
|
648
742
|
if (rec.is_error) events.push({ kind: 'api_error', at: t, detail: textOf(rec.result) || 'result is_error' });
|
|
649
743
|
continue;
|
|
@@ -689,6 +783,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
689
783
|
if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = JSON.stringify(structured).length;
|
|
690
784
|
// stream-json puts is_error only in block; Claude Code also stores toolUseResult as an Error string
|
|
691
785
|
if (!tr.is_error && typeof structured === 'string' && /^Error/i.test(structured)) { call.status = 'error'; call.isError = true; }
|
|
786
|
+
if (call.isError) call.denial = denialKind(rec, text);
|
|
692
787
|
} else {
|
|
693
788
|
toolCalls.push(mkOrphanResult(tr, rec, a.id, text, turns.length ? turns[turns.length - 1].index : null));
|
|
694
789
|
}
|
|
@@ -701,6 +796,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
701
796
|
let kind = 'human';
|
|
702
797
|
if (rec.isCompactSummary) kind = 'compact';
|
|
703
798
|
else if (rec.isMeta) kind = 'meta';
|
|
799
|
+
else if (/^\[Request interrupted by user/.test(text.trim())) kind = 'interrupt';
|
|
704
800
|
else if (/^<(system-reminder|local-command|command-name|bash-input|bash-stdout)/.test(text.trim())) kind = 'meta';
|
|
705
801
|
ensureTurn(rec, kind, text);
|
|
706
802
|
continue;
|
|
@@ -723,16 +819,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
723
819
|
turn.requestIds.push(req.id);
|
|
724
820
|
a.requestCount++;
|
|
725
821
|
if (msg.model) { pushModel(meta, msg.model); if (!a.model) a.model = msg.model; }
|
|
726
|
-
const u = usageFrom(msg.usage);
|
|
727
|
-
if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; addUsage(turn.usage, u); addUsage(a.usage, u); }
|
|
728
822
|
} else {
|
|
729
823
|
if (t != null) { if (req.start == null || t < req.start) req.start = t; if (req.end == null || t > req.end) req.end = t; }
|
|
730
|
-
// usage: if the first record lacked it and a later one has it, take it once
|
|
731
|
-
if (isEmptyUsage(req.usage)) {
|
|
732
|
-
const u = usageFrom(msg.usage);
|
|
733
|
-
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); }
|
|
734
|
-
}
|
|
735
824
|
}
|
|
825
|
+
maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
|
|
736
826
|
req.records++;
|
|
737
827
|
if (msg.stop_reason) req.stopReason = msg.stop_reason;
|
|
738
828
|
if (rec.isApiErrorMessage) req.isApiError = true;
|
|
@@ -745,7 +835,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
745
835
|
id: b.id || (key + ':' + blockIndex), agent: a.id, requestId: req.id, name: b.name || 'unknown', input: b.input || {},
|
|
746
836
|
start: t, end: null, durationMs: null, status: 'orphan', isError: false, resultText: '', resultChars: 0,
|
|
747
837
|
resultStructured: null, category: toolCategory(b.name), turnIndex: req.turnIndex, subagentId: null,
|
|
748
|
-
inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0,
|
|
838
|
+
inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0, denial: null,
|
|
749
839
|
};
|
|
750
840
|
toolCalls.push(call);
|
|
751
841
|
toolCallById.set(call.id, call);
|
|
@@ -767,7 +857,12 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
767
857
|
}
|
|
768
858
|
|
|
769
859
|
// Finalise
|
|
770
|
-
for (const req of requests)
|
|
860
|
+
for (const req of requests) {
|
|
861
|
+
req.blocks.sort((x, y) => x.index - y.index);
|
|
862
|
+
req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
|
|
863
|
+
addUsage(turns[req.turnIndex].usage, req.usage);
|
|
864
|
+
addUsage(agents.get(req.agent).usage, req.usage);
|
|
865
|
+
}
|
|
771
866
|
for (const turn of turns) if (turn.end == null) turn.end = meta.end;
|
|
772
867
|
// Response timing. Assistant record timestamps are written when a block finishes streaming,
|
|
773
868
|
// so a request's "start" is really its first completed block. We measure from the moment the
|
|
@@ -799,9 +894,14 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
799
894
|
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))));
|
|
800
895
|
if (parent) ag.parentToolUseId = parent.id;
|
|
801
896
|
}
|
|
897
|
+
// Workflow agents live in subagents/workflows/<runId>/; the Workflow call's result names that runId.
|
|
898
|
+
if (!ag.parentToolUseId && ag.workflowRun) {
|
|
899
|
+
const parent = toolCalls.find((c) => c.name === 'Workflow' && ((c.resultStructured && c.resultStructured.runId === ag.workflowRun) || (c.resultText && c.resultText.includes(ag.workflowRun))));
|
|
900
|
+
if (parent) ag.parentToolUseId = parent.id;
|
|
901
|
+
}
|
|
802
902
|
if (ag.parentToolUseId) {
|
|
803
903
|
const parent = toolCallById.get(ag.parentToolUseId);
|
|
804
|
-
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; }
|
|
904
|
+
if (parent) { if (!parent.subagentId) parent.subagentId = ag.id; (parent.subagentIds || (parent.subagentIds = [])).push(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; }
|
|
805
905
|
}
|
|
806
906
|
}
|
|
807
907
|
|
|
@@ -809,12 +909,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
809
909
|
const usage = emptyUsage();
|
|
810
910
|
for (const r of requests) addUsage(usage, r.usage);
|
|
811
911
|
const humanIdleMs = turns.reduce((s, tn) => s + (tn.idleBeforeMs || 0), 0);
|
|
812
|
-
|
|
912
|
+
// stream-json runs carry no per-record timestamps and no human prompt record, but the result
|
|
913
|
+
// record reports duration_ms and num_turns; use those rather than showing "—" and 0.
|
|
914
|
+
const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : (meta.reportedDurationMs != null ? meta.reportedDurationMs : null);
|
|
915
|
+
const humanTurns = turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length;
|
|
813
916
|
const totals = {
|
|
814
917
|
usage, wallMs, humanIdleMs, activeMs: wallMs != null ? Math.max(0, wallMs - humanIdleMs) : null,
|
|
815
918
|
requests: requests.length, toolCalls: toolCalls.length, toolErrors: toolCalls.filter((c) => c.isError).length,
|
|
816
919
|
orphans: toolCalls.filter((c) => c.status === 'orphan').length,
|
|
817
|
-
turns:
|
|
920
|
+
turns: humanTurns || (meta.reportedTurns != null ? meta.reportedTurns : 0),
|
|
818
921
|
toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
|
|
819
922
|
modelTimeMs: agents.get('main').modelTimeMs || 0,
|
|
820
923
|
cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
|
|
@@ -852,9 +955,20 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
852
955
|
if (!byTurnSig.has(sig)) byTurnSig.set(sig, []);
|
|
853
956
|
byTurnSig.get(sig).push(c);
|
|
854
957
|
}
|
|
958
|
+
const callIndex = new Map(calls.map((c, i) => [c, i]));
|
|
855
959
|
for (const group of byTurnSig.values()) {
|
|
856
960
|
if (group.length >= o.retryLoopMin) {
|
|
857
961
|
const errs = group.filter((c) => c.isError).length;
|
|
962
|
+
if (isWait(group[0]) && errs < 2) continue; // polling a running task; failing polls still count
|
|
963
|
+
if (errs < 2) {
|
|
964
|
+
// Re-running a test or taking another screenshot after something changed is observing, not
|
|
965
|
+
// looping: only flag successful repeats that returned the same thing with nothing in between.
|
|
966
|
+
const inGroup = new Set(group);
|
|
967
|
+
const between = calls.slice(callIndex.get(group[0]) + 1, callIndex.get(group[group.length - 1]));
|
|
968
|
+
const changed = between.some((c) => c.agent === group[0].agent && !inGroup.has(c) && c.category !== 'read');
|
|
969
|
+
const same = group.every((c) => c.resultText === group[0].resultText && c.imageBytes === group[0].imageBytes);
|
|
970
|
+
if (changed || !same) continue;
|
|
971
|
+
}
|
|
858
972
|
push({
|
|
859
973
|
id: 'retry-loop', severity: errs >= 2 ? 'error' : 'warn',
|
|
860
974
|
title: `${group[0].name} called ${group.length}× with identical input`,
|
|
@@ -864,14 +978,16 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
864
978
|
}
|
|
865
979
|
}
|
|
866
980
|
|
|
867
|
-
// failed-tool: per tool error rate
|
|
981
|
+
// failed-tool: per tool error rate. Calls the human or a permission rule stopped are not the
|
|
982
|
+
// tool's failure (see permission-denied); one stray error in a long session is not a pattern.
|
|
868
983
|
const byTool = new Map();
|
|
869
|
-
for (const c of calls) { if (c.unmatched) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
|
|
984
|
+
for (const c of calls) { if (c.unmatched || c.denial) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
|
|
870
985
|
for (const [name, list] of byTool) {
|
|
871
986
|
const errs = list.filter((c) => c.isError);
|
|
872
987
|
if (!errs.length) continue;
|
|
873
988
|
const rate = errs.length / list.length;
|
|
874
989
|
const bad = list.length >= o.failedToolMinCalls && rate >= o.failedToolRate;
|
|
990
|
+
if (!bad && (errs.length < o.failedToolWarnMinErrors || rate < o.failedToolWarnRate)) continue;
|
|
875
991
|
push({
|
|
876
992
|
id: 'failed-tool', severity: bad ? 'error' : 'warn',
|
|
877
993
|
title: `${name} failed ${errs.length} of ${list.length} calls (${Math.round(rate * 100)}%)`,
|
|
@@ -880,6 +996,18 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
880
996
|
});
|
|
881
997
|
}
|
|
882
998
|
|
|
999
|
+
// permission-denied: calls that never ran because the human rejected them, a permission rule or
|
|
1000
|
+
// the auto-mode classifier blocked them, or the human interrupted.
|
|
1001
|
+
const denied = calls.filter((c) => c.denial);
|
|
1002
|
+
if (denied.length) {
|
|
1003
|
+
const kinds = {}; for (const c of denied) kinds[c.denial] = (kinds[c.denial] || 0) + 1;
|
|
1004
|
+
push({
|
|
1005
|
+
id: 'permission-denied', severity: 'info', title: `${denied.length} tool call${denied.length > 1 ? 's' : ''} denied or interrupted`,
|
|
1006
|
+
detail: Object.entries(kinds).map(([k, n]) => `${k} ×${n}`).join(', ') + ' — ' + summariseNames(denied),
|
|
1007
|
+
evidence: { toolCallIds: denied.map((c) => c.id) }, metric: denied.length,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
|
|
883
1011
|
// orphan-tool
|
|
884
1012
|
const orphans = calls.filter((c) => c.status === 'orphan');
|
|
885
1013
|
if (orphans.length) push({
|
|
@@ -904,18 +1032,52 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
904
1032
|
};
|
|
905
1033
|
for (const c of calls) {
|
|
906
1034
|
if (c.agent !== ag.id || c.unmatched) continue;
|
|
907
|
-
|
|
1035
|
+
// MCP calls that aren't reads are actions (browser navigate/click, device commands) and end a run;
|
|
1036
|
+
// agent and user calls don't.
|
|
1037
|
+
if (c.category === 'read') run.push(c); else if (c.category === 'write' || c.category === 'exec' || c.category === 'mcp') flush();
|
|
908
1038
|
}
|
|
909
1039
|
flush();
|
|
910
1040
|
}
|
|
911
1041
|
|
|
912
|
-
//
|
|
1042
|
+
// duplicate-subagent-read: the same file read by several agents, each paying for it in its own context
|
|
1043
|
+
const sharedReads = new Set(); // reported here, so oversized-result below skips them
|
|
1044
|
+
const readsByFile = new Map();
|
|
913
1045
|
for (const c of calls) {
|
|
914
|
-
if (c.
|
|
1046
|
+
if (c.name !== 'Read' || c.isError || !c.input || !c.input.file_path) continue;
|
|
1047
|
+
const k = String(c.input.file_path).replace(/\\/g, '/').toLowerCase();
|
|
1048
|
+
if (!readsByFile.has(k)) readsByFile.set(k, []);
|
|
1049
|
+
readsByFile.get(k).push(c);
|
|
1050
|
+
}
|
|
1051
|
+
for (const list of readsByFile.values()) {
|
|
1052
|
+
const agentIds = new Set(list.map((c) => c.agent));
|
|
1053
|
+
if (agentIds.size < o.duplicateReadAgents) continue;
|
|
1054
|
+
const chars = list.reduce((s, c) => s + c.resultChars, 0);
|
|
1055
|
+
for (const c of list) sharedReads.add(c);
|
|
1056
|
+
const largest = Math.max(...list.map((c) => c.resultChars));
|
|
1057
|
+
push({
|
|
1058
|
+
id: 'duplicate-subagent-read', severity: largest >= o.oversizedResultError ? 'error' : chars >= o.duplicateReadWarnChars ? 'warn' : 'info',
|
|
1059
|
+
title: `Same file read by ${agentIds.size} agents (${fmtInt(chars)} chars)`, // path only in detail, which --redact blanks
|
|
1060
|
+
detail: `${list.length} reads of ${list[0].input.file_path} across ${agentIds.size} agents; each copy is billed in that agent's context on every later request.`,
|
|
1061
|
+
evidence: { toolCallIds: list.map((c) => c.id) }, metric: chars,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// oversized-result: one line per call, or one per tool when a tool does it repeatedly
|
|
1066
|
+
const bigByTool = new Map();
|
|
1067
|
+
for (const c of calls) if (c.resultChars >= o.oversizedResultWarn && !sharedReads.has(c)) { if (!bigByTool.has(c.name)) bigByTool.set(c.name, []); bigByTool.get(c.name).push(c); }
|
|
1068
|
+
for (const [name, list] of bigByTool) {
|
|
1069
|
+
const largest = Math.max(...list.map((c) => c.resultChars)), total = list.reduce((s, c) => s + c.resultChars, 0);
|
|
1070
|
+
const ranged = list.every(hasRange);
|
|
1071
|
+
const detail = 'Large tool results stay in context for the rest of the session. ' + (ranged ? 'The call already set a range; long lines or a wide window still cost, so narrow it or grep first.' : 'Consider head/limit, grep, or a subagent.');
|
|
1072
|
+
if (list.length >= o.groupRepeatsMin) push({
|
|
1073
|
+
id: 'oversized-result', severity: largest >= o.oversizedResultError ? 'error' : 'warn',
|
|
1074
|
+
title: `${name} returned over ${fmtInt(o.oversizedResultWarn)} chars ${list.length} times (largest ${fmtInt(largest)}, ${fmtInt(total)} in all)`,
|
|
1075
|
+
detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: largest,
|
|
1076
|
+
});
|
|
1077
|
+
else for (const c of list) push({
|
|
915
1078
|
id: 'oversized-result', severity: c.resultChars >= o.oversizedResultError ? 'error' : 'warn',
|
|
916
1079
|
title: `${c.name} returned ${fmtInt(c.resultChars)} chars`,
|
|
917
|
-
detail:
|
|
918
|
-
evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
|
|
1080
|
+
detail, evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
|
|
919
1081
|
});
|
|
920
1082
|
}
|
|
921
1083
|
|
|
@@ -935,21 +1097,45 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
935
1097
|
if (r.contextTokens >= o.contextBloatWarn && !crossed) crossed = r;
|
|
936
1098
|
if (!peak || r.contextTokens > peak.contextTokens) peak = r;
|
|
937
1099
|
}
|
|
938
|
-
if (crossed)
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1100
|
+
if (crossed) {
|
|
1101
|
+
// What the large context cost: every request (any agent) sent with more than the threshold.
|
|
1102
|
+
let above = 0, all = 0, n = 0;
|
|
1103
|
+
for (const r of reqs) { const c = requestCost(r.usage, rateFor(r.model, o.rates)); if (c == null) continue; all += c; if (r.contextTokens >= o.contextBloatWarn) { above += c; n++; } }
|
|
1104
|
+
const spend = all > 0 ? ` · ${usd(above)} (${Math.round(above / all * 100)}% of cost) spent above ${fmtInt(o.contextBloatWarn)}` : '';
|
|
1105
|
+
push({
|
|
1106
|
+
id: 'context-bloat', severity: peak.contextTokens >= o.contextBloatError ? 'error' : 'warn',
|
|
1107
|
+
title: `Context reached ${fmtInt(peak.contextTokens)} tokens${spend}`,
|
|
1108
|
+
detail: `First crossed ${fmtInt(o.contextBloatWarn)} at request #${reqs.indexOf(crossed) + 1} (turn ${crossed.turnIndex + 1}); ${n} request${n === 1 ? '' : 's'} ran above it. Every one of them re-reads that prefix.`,
|
|
1109
|
+
evidence: { requestIds: [crossed.id, peak.id], turnIndex: crossed.turnIndex }, metric: peak.contextTokens, cost: all > 0 ? above : null,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
944
1112
|
|
|
945
|
-
// cache-churn
|
|
946
|
-
|
|
1113
|
+
// cache-churn / idle-cache-expiry: a request that re-wrote prefix the previous request of the same
|
|
1114
|
+
// agent had already cached. A big cache write on its own is usually just a new tool result being
|
|
1115
|
+
// cached, which is expected. After a gap longer than the cache lifetime the miss is expiry, not
|
|
1116
|
+
// invalidation; right after a compaction it is an expected rebuild.
|
|
1117
|
+
const prevByAgent = new Map(); const ttlByAgent = new Map();
|
|
947
1118
|
for (const r of reqs) {
|
|
948
|
-
|
|
949
|
-
if (r.usage.
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1119
|
+
const prev = prevByAgent.get(r.agent); prevByAgent.set(r.agent, r);
|
|
1120
|
+
if (r.usage.cacheWrite1h > 0) ttlByAgent.set(r.agent, 3600e3); else if (r.usage.cacheWrite5m > 0 && !ttlByAgent.has(r.agent)) ttlByAgent.set(r.agent, 300e3);
|
|
1121
|
+
if (!prev) continue;
|
|
1122
|
+
const cached = prev.usage.cacheRead + prev.usage.cacheWrite;
|
|
1123
|
+
const missed = cached - r.usage.cacheRead;
|
|
1124
|
+
if (missed < o.cacheChurnMin || missed < cached * o.cacheMissMinShare) continue;
|
|
1125
|
+
const sent = r.responseFrom != null ? r.responseFrom : r.start;
|
|
1126
|
+
if (prev.end != null && sent != null && trace.events.some((e) => e.kind === 'compact' && e.at != null && e.at >= prev.end && e.at <= sent)) continue;
|
|
1127
|
+
const idle = prev.end != null && sent != null ? sent - prev.end : null;
|
|
1128
|
+
const ttl = ttlByAgent.get(r.agent) || 300e3;
|
|
1129
|
+
const n = reqs.indexOf(r) + 1;
|
|
1130
|
+
if (idle != null && idle > ttl) push({
|
|
1131
|
+
id: 'idle-cache-expiry', severity: 'info', title: `${fmtInt(missed)} tokens re-cached after ${fmtDur(idle)} idle`,
|
|
1132
|
+
detail: `Request #${n}${r.agent !== 'main' ? ' (subagent)' : ''}: the gap was longer than the ${ttl >= 3600e3 ? '1-hour' : '5-minute'} cache lifetime, so the whole prefix was written again.`,
|
|
1133
|
+
evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
|
|
1134
|
+
});
|
|
1135
|
+
else push({
|
|
1136
|
+
id: 'cache-churn', severity: 'info', title: `${fmtInt(missed)} cached tokens re-written at request #${n}`,
|
|
1137
|
+
detail: `The previous request had ${fmtInt(cached)} tokens cached; this one read only ${fmtInt(r.usage.cacheRead)} back${idle != null ? ` ${fmtDur(idle)} later` : ''}. Something early in the prompt changed (system prompt, tool list, early messages).`,
|
|
1138
|
+
evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
|
|
953
1139
|
});
|
|
954
1140
|
}
|
|
955
1141
|
|
|
@@ -960,14 +1146,40 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
960
1146
|
evidence: {}, metric: trace.totals.cacheHitRatio,
|
|
961
1147
|
});
|
|
962
1148
|
|
|
963
|
-
// slow-tool
|
|
1149
|
+
// slow-tool: blocking waits roll up per background task, other slow calls per tool when repeated
|
|
1150
|
+
const waits = new Map(), slowByTool = new Map();
|
|
964
1151
|
for (const c of calls) {
|
|
965
|
-
if (c.
|
|
1152
|
+
if (isWait(c)) { const k = c.agent + '|' + c.name + '|' + waitTarget(c); if (!waits.has(k)) waits.set(k, []); waits.get(k).push(c); continue; }
|
|
1153
|
+
// Time spent on a question to the human, or on a permission prompt that ended in a denial, is the human's.
|
|
1154
|
+
if (c.category === 'user' || c.denial) continue;
|
|
1155
|
+
if (c.durationMs != null && c.durationMs >= o.slowToolInfoMs) { if (!slowByTool.has(c.name)) slowByTool.set(c.name, []); slowByTool.get(c.name).push(c); }
|
|
1156
|
+
}
|
|
1157
|
+
for (const [name, list] of slowByTool) {
|
|
1158
|
+
const longest = Math.max(...list.map((c) => c.durationMs)), total = list.reduce((s, c) => s + c.durationMs, 0);
|
|
1159
|
+
const detail = list[0].category === 'agent' ? 'Subagent run time.' : 'Long-running tool call.';
|
|
1160
|
+
if (list.length >= o.groupRepeatsMin) push({
|
|
1161
|
+
id: 'slow-tool', severity: longest >= o.slowToolWarnMs ? 'warn' : 'info',
|
|
1162
|
+
title: `${name} took over ${fmtDur(o.slowToolInfoMs)} ${list.length} times (longest ${fmtDur(longest)}, ${fmtDur(total)} in all)`,
|
|
1163
|
+
detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: total,
|
|
1164
|
+
});
|
|
1165
|
+
else for (const c of list) push({
|
|
966
1166
|
id: 'slow-tool', severity: c.durationMs >= o.slowToolWarnMs ? 'warn' : 'info',
|
|
967
|
-
title: `${c.name} took ${fmtDur(c.durationMs)}`, detail
|
|
1167
|
+
title: `${c.name} took ${fmtDur(c.durationMs)}`, detail,
|
|
968
1168
|
evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.durationMs,
|
|
969
1169
|
});
|
|
970
1170
|
}
|
|
1171
|
+
// One finding per waited-on task, always info: the time belongs to the background job.
|
|
1172
|
+
for (const group of waits.values()) {
|
|
1173
|
+
const ms = group.reduce((s, c) => s + (c.durationMs || 0), 0);
|
|
1174
|
+
if (ms < o.slowToolInfoMs) continue;
|
|
1175
|
+
const target = waitTarget(group[0]);
|
|
1176
|
+
push({
|
|
1177
|
+
id: 'slow-tool', severity: 'info',
|
|
1178
|
+
title: `${group[0].name} waited ${fmtDur(ms)} on ${target ? 'task ' + target : 'a background task'}${group.length > 1 ? ` (${group.length} polls)` : ''}`,
|
|
1179
|
+
detail: 'Blocking wait on background work: the time is the task\'s run time, not a slow tool.',
|
|
1180
|
+
evidence: { toolCallIds: group.map((c) => c.id), turnIndex: group[0].turnIndex }, metric: ms,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
971
1183
|
|
|
972
1184
|
// slow-model: a long response that produced little — API latency, rate limiting or a stall,
|
|
973
1185
|
// as opposed to a long response that was simply writing a lot (reported separately as info).
|
|
@@ -980,9 +1192,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
980
1192
|
detail: `Request #${n} (${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s): API latency, rate limiting or a stalled stream.`,
|
|
981
1193
|
evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
|
|
982
1194
|
});
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1195
|
+
// A long response that wrote a lot at a normal rate is just a big output. Only a slow rate is worth a line.
|
|
1196
|
+
else if (r.tokensPerSec != null && r.tokensPerSec < o.longGenerationMaxTokPerSec) push({
|
|
1197
|
+
id: 'long-generation', severity: 'info', title: `${fmtDur(r.responseMs)} writing ${fmtInt(r.usage.output)} tokens (${r.tokensPerSec.toFixed(1)} tok/s)`,
|
|
1198
|
+
detail: `Request #${n} (${r.blocksSummary}) streamed well below the usual rate: a partial stall or a slow API period.`,
|
|
986
1199
|
evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
|
|
987
1200
|
});
|
|
988
1201
|
}
|
|
@@ -1014,10 +1227,18 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1014
1227
|
detail: 'Most of the spend happened in delegated work.', evidence: {}, metric: subTokens / allTokens,
|
|
1015
1228
|
});
|
|
1016
1229
|
|
|
1017
|
-
// long-turn
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1230
|
+
// long-turn: main conversation only (a subagent's whole run is one turn by design), numbered like
|
|
1231
|
+
// the viewer's T1, T2… (human prompts).
|
|
1232
|
+
// Meta turns (injected reminders, interrupts) between two prompts belong to the stretch before them.
|
|
1233
|
+
const stretches = [];
|
|
1234
|
+
for (const tn of trace.turns) {
|
|
1235
|
+
if (tn.agent !== 'main') continue;
|
|
1236
|
+
if (tn.promptKind === 'human' || !stretches.length) stretches.push({ first: tn, no: stretches.length + 1, ids: [] });
|
|
1237
|
+
stretches[stretches.length - 1].ids.push(...tn.toolCallIds);
|
|
1238
|
+
}
|
|
1239
|
+
for (const st of stretches) if (st.ids.length >= o.longTurnTools) push({
|
|
1240
|
+
id: 'long-turn', severity: 'info', title: `Turn ${st.no} made ${st.ids.length} tool calls`,
|
|
1241
|
+
detail: 'A very long autonomous stretch. Worth a skim for wasted work.', evidence: { turnIndex: st.first.index, toolCallIds: st.ids }, metric: st.ids.length,
|
|
1021
1242
|
});
|
|
1022
1243
|
|
|
1023
1244
|
const order = { error: 0, warn: 1, info: 2 };
|
|
@@ -1080,6 +1301,210 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1080
1301
|
};
|
|
1081
1302
|
}
|
|
1082
1303
|
|
|
1304
|
+
// ---------------------------------------------------------------------------
|
|
1305
|
+
// Advice — one sentence per rule on what to do differently. Mechanical, like the rules.
|
|
1306
|
+
// ---------------------------------------------------------------------------
|
|
1307
|
+
const ADVICE = {
|
|
1308
|
+
'retry-loop': 'When a call fails, change something before calling again: read the error, fix the input or the environment, or ask the user — never repeat an identical call.',
|
|
1309
|
+
'failed-tool': 'Check the first error message of the failing tool before retrying; if the tool itself is broken, switch tools or tell the user instead of pushing on.',
|
|
1310
|
+
'orphan-tool': 'A call with no result means the session ended mid-call; if you resume, re-run it or confirm its side effects before building on it.',
|
|
1311
|
+
'exploration-run': 'Batch exploration: use one Grep or Glob with a wider pattern, or delegate a broad search to a subagent, then commit to an edit or command sooner.',
|
|
1312
|
+
'oversized-result': 'Ask tools for less: head/limit/offset on reads, a tighter grep pattern, a file listing instead of a dump, or a subagent that returns a summary.',
|
|
1313
|
+
'image-heavy': 'Take fewer screenshots and crop or downscale them; read text with a page-text tool where one exists, since every image is re-billed on each later request.',
|
|
1314
|
+
'context-bloat': 'Keep the prompt small: summarise long results into notes, avoid re-reading files already in context, and hand large investigations to subagents.',
|
|
1315
|
+
'cache-churn': 'Avoid changing anything at the top of the prompt mid-session (system prompt, tool list, early messages), because it invalidates the cached prefix and re-bills it.',
|
|
1316
|
+
'low-cache-hit': 'Long stable prefixes cache well; frequent tool-list or system-prompt changes and many tiny sessions do not.',
|
|
1317
|
+
'slow-tool': 'For calls that take minutes, run them in the background, tighten the command, or set a timeout, instead of blocking the whole turn.',
|
|
1318
|
+
'slow-model': 'A slow, small response is usually API latency or a stall; nothing to fix in the session itself, but note it when reporting session time.',
|
|
1319
|
+
'long-generation': 'Large single outputs are normal for file writes; split very large files into parts if a stall (low tok/s) shows up.',
|
|
1320
|
+
'max-tokens': 'The output was truncated: write large files in sections and keep single responses under the output limit.',
|
|
1321
|
+
'api-error': 'An API error interrupted the session; check what was lost and re-run the affected step rather than assuming it completed.',
|
|
1322
|
+
'hook-error': 'A hook failed; read the hook error, fix the hook command or its permissions, then re-run the step it guarded.',
|
|
1323
|
+
'compaction': 'Context was compacted; state that matters later (decisions, file paths, test status) should be written to a file or note before it is summarised away.',
|
|
1324
|
+
'thinking-heavy': 'Most output tokens were thinking; fine for hard problems, but for routine tool loops a lower effort setting is cheaper and just as good.',
|
|
1325
|
+
'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
|
|
1326
|
+
'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
|
|
1327
|
+
'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
|
|
1328
|
+
'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
|
|
1329
|
+
'idle-cache-expiry': 'The cache expired while the session sat idle; resume sooner, use the 1-hour cache lifetime where available, or start a fresh session with a summary instead of re-sending the whole history.',
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
// Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
|
|
1333
|
+
function adviceFor(f, trace) {
|
|
1334
|
+
if (f.id === 'slow-tool') {
|
|
1335
|
+
const ids = (f.evidence && f.evidence.toolCallIds) || [];
|
|
1336
|
+
const c = trace.toolCalls.find((x) => x.id === ids[0]);
|
|
1337
|
+
if (c && c.category === 'user') return null; // waiting on the human is not the agent's time
|
|
1338
|
+
if (c && c.category === 'agent') return 'Subagent run time: give it a narrower brief and ask for a short structured return so the parent is not blocked for as long.';
|
|
1339
|
+
if (c && isWait(c)) return 'Waiting on a long background task: keep doing independent work while it runs, or tell the user it is still going, instead of blocking the turn on it.';
|
|
1340
|
+
}
|
|
1341
|
+
if (f.id === 'oversized-result') {
|
|
1342
|
+
const c = trace.toolCalls.find((x) => x.id === ((f.evidence && f.evidence.toolCallIds) || [])[0]);
|
|
1343
|
+
if (c && hasRange(c)) return 'The read already had a range and still returned a lot: grep for the part you need first, or read a narrower window.';
|
|
1344
|
+
}
|
|
1345
|
+
return ADVICE[f.id] || null;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function inputSummary(c) {
|
|
1349
|
+
const i = (c && c.input) || {};
|
|
1350
|
+
if (i.command) return String(i.command);
|
|
1351
|
+
if (i.file_path) return String(i.file_path) + (i.pattern ? ' ' + i.pattern : '');
|
|
1352
|
+
if (i.pattern) return String(i.pattern);
|
|
1353
|
+
if (i.query) return String(i.query);
|
|
1354
|
+
if (i.description) return String(i.description);
|
|
1355
|
+
if (i.url) return String(i.url);
|
|
1356
|
+
if (i.prompt) return String(i.prompt);
|
|
1357
|
+
if (i.path) return String(i.path);
|
|
1358
|
+
const s = JSON.stringify(i);
|
|
1359
|
+
return s === '{}' ? '' : s;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// ---------------------------------------------------------------------------
|
|
1363
|
+
// Markdown report — written to be handed back to the agent as well as read by a human.
|
|
1364
|
+
// opts: { redact, advice=true, instruction=true, maxEvidence=6, title }
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
1366
|
+
function usd(v) { return v == null ? '—' : v < 0.01 ? '$' + v.toFixed(4) : v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2); }
|
|
1367
|
+
function pctStr(x) { return x == null ? '—' : Math.round(x * 100) + '%'; }
|
|
1368
|
+
function oneLine(s, n) { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|
1369
|
+
function blank(s) { return '«' + String(s == null ? '' : s).length + ' chars»'; }
|
|
1370
|
+
|
|
1371
|
+
function toolTable(trace) {
|
|
1372
|
+
const rows = new Map();
|
|
1373
|
+
for (const c of trace.toolCalls) { if (c.unmatched) continue; if (!rows.has(c.name)) rows.set(c.name, { name: c.name, calls: 0, errors: 0, time: 0 }); const r = rows.get(c.name); r.calls++; if (c.isError) r.errors++; r.time += c.durationMs || 0; }
|
|
1374
|
+
return Array.from(rows.values()).sort((a, b) => b.time - a.time || b.calls - a.calls);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Rules whose detail quotes tool output or model text (everything else is names and numbers).
|
|
1378
|
+
const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read']);
|
|
1379
|
+
function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
|
|
1380
|
+
|
|
1381
|
+
function reportMarkdown(trace, findings, cost, opts) {
|
|
1382
|
+
opts = opts || {};
|
|
1383
|
+
const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
|
|
1384
|
+
const T = trace.totals, m = trace.meta, u = T.usage;
|
|
1385
|
+
const R = (s) => redact ? blank(s) : s;
|
|
1386
|
+
const total = cost ? (cost.reported != null ? cost.reported : cost.total) : null;
|
|
1387
|
+
const turnNo = (idx) => idx != null ? idx + 1 : null; // same numbering as the finding details and the viewer drawer
|
|
1388
|
+
const advised = new Set();
|
|
1389
|
+
const out = [];
|
|
1390
|
+
out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
|
|
1391
|
+
out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
|
|
1392
|
+
out.push(`- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turn${T.turns === 1 ? '' : 's'} · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
|
|
1393
|
+
out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}`, '');
|
|
1394
|
+
if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
|
|
1395
|
+
else {
|
|
1396
|
+
out.push(`## Findings (${findings.length})`, '');
|
|
1397
|
+
findings.forEach((f, i) => {
|
|
1398
|
+
out.push(`### ${i + 1}. ${f.severity.toUpperCase()} \`${f.id}\` — ${f.title}`, '', redact ? redactDetail(f) : f.detail, '');
|
|
1399
|
+
const ev = f.evidence || {};
|
|
1400
|
+
const lines = [];
|
|
1401
|
+
for (const id of ev.toolCallIds || []) { const c = trace.toolCalls.find((x) => x.id === id); if (!c) continue; const tn = turnNo(c.turnIndex); lines.push(`- ${c.name}${c.isError ? ' (error)' : ''}${c.durationMs != null ? ' · ' + fmtDur(c.durationMs) : ''}${tn != null ? ' · turn ' + tn : ''}${c.agent !== 'main' ? ' · subagent' : ''}: \`${oneLine(R(inputSummary(c)), 100).replace(/`/g, "'") || '(no input)'}\``); }
|
|
1402
|
+
for (const id of ev.requestIds || []) { const r = trace.requests.find((x) => x.id === id); if (!r) continue; const n = trace.requests.indexOf(r) + 1; const tn = turnNo(r.turnIndex); lines.push(`- request #${n}${tn != null ? ' · turn ' + tn : ''}: context ${fmtInt(r.contextTokens)} · output ${fmtInt(r.usage.output)}${r.responseMs != null ? ' · ' + fmtDur(r.responseMs) : ''}`); }
|
|
1403
|
+
if (lines.length) { out.push('Evidence:', ''); out.push(...lines.slice(0, maxEv)); if (lines.length > maxEv) out.push(`- … ${lines.length - maxEv} more`); out.push(''); }
|
|
1404
|
+
const adv = opts.advice !== false && !advised.has(f.id) ? adviceFor(f, trace) : null;
|
|
1405
|
+
if (adv) { advised.add(f.id); out.push(`Next time: ${adv}`, ''); }
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
const tools = toolTable(trace);
|
|
1409
|
+
if (tools.length) { out.push('## Tools', '', '| tool | calls | errors | total time |', '|---|---|---|---|'); for (const r of tools) out.push(`| ${r.name} | ${r.calls} | ${r.errors} | ${fmtDur(r.time)} |`); out.push(''); }
|
|
1410
|
+
if (opts.instruction !== false) out.push('## What to do with this', '', 'This is a mechanical review of the session transcript, generated by Glassbox (no AI involved). If you are the agent that ran this session: acknowledge the findings that apply, say in one or two sentences what you will do differently next session, and do not redo any work. If you are a human: the evidence lines are permalinks into the Glassbox viewer for this session.', '');
|
|
1411
|
+
out.push('_Generated by Glassbox ' + VERSION + '._', '');
|
|
1412
|
+
return out.join('\n');
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// ---------------------------------------------------------------------------
|
|
1416
|
+
// Compare two analysed sessions ({trace, findings, cost}).
|
|
1417
|
+
// ---------------------------------------------------------------------------
|
|
1418
|
+
const METRICS = [
|
|
1419
|
+
{ key: 'wallMs', label: 'Wall time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.wallMs },
|
|
1420
|
+
{ key: 'activeMs', label: 'Active time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.activeMs },
|
|
1421
|
+
{ key: 'turns', label: 'Turns', fmt: 'int', dir: null, get: (s) => s.trace.totals.turns },
|
|
1422
|
+
{ key: 'requests', label: 'API requests', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.requests },
|
|
1423
|
+
{ key: 'toolCalls', label: 'Tool calls', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolCalls },
|
|
1424
|
+
{ key: 'toolErrors', label: 'Tool errors', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolErrors },
|
|
1425
|
+
{ key: 'toolTimeMs', label: 'Time in tools', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.toolTimeMs : null },
|
|
1426
|
+
{ key: 'modelTimeMs', label: 'Time generating', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.modelTimeMs : null },
|
|
1427
|
+
{ key: 'contextServed', label: 'Context served', fmt: 'tok', dir: 'low', get: (s) => { const u = s.trace.totals.usage; const v = u.input + u.cacheRead + u.cacheWrite; return v || (s.trace.requests.length ? 0 : null); } },
|
|
1428
|
+
{ key: 'peakContext', label: 'Peak prompt', fmt: 'tok', dir: 'low', get: (s) => s.trace.requests.length ? Math.max(0, ...s.trace.requests.map((r) => r.contextTokens)) : null },
|
|
1429
|
+
{ key: 'cacheHitRatio', label: 'Cache hit', fmt: 'pct', dir: 'high', get: (s) => s.trace.totals.cacheHitRatio },
|
|
1430
|
+
{ key: 'output', label: 'Output tokens', fmt: 'tok', dir: 'low', get: (s) => s.trace.requests.length ? s.trace.totals.usage.output : null },
|
|
1431
|
+
{ key: 'thinkingShare', label: 'Thinking share', fmt: 'pct', dir: null, get: (s) => { const u = s.trace.totals.usage; return u.output ? u.thinking / u.output : null; } },
|
|
1432
|
+
{ key: 'cost', label: 'Est. cost', fmt: 'usd', dir: 'low', get: (s) => s.cost ? (s.cost.reported != null ? s.cost.reported : s.cost.total) : null },
|
|
1433
|
+
{ key: 'findings', label: 'Findings', fmt: 'int', dir: 'low', get: (s) => s.findings.length },
|
|
1434
|
+
{ key: 'errorFindings', label: 'Error-level findings', fmt: 'int', dir: 'low', get: (s) => s.findings.filter((f) => f.severity === 'error').length },
|
|
1435
|
+
];
|
|
1436
|
+
function fmtMetric(v, fmt) {
|
|
1437
|
+
if (v == null) return '—';
|
|
1438
|
+
if (fmt === 'dur') return fmtDur(v);
|
|
1439
|
+
if (fmt === 'pct') return pctStr(v);
|
|
1440
|
+
if (fmt === 'usd') return usd(v);
|
|
1441
|
+
return fmtInt(v);
|
|
1442
|
+
}
|
|
1443
|
+
// "×2.4" when B is bigger, "÷2.4" when smaller; never "×0.00".
|
|
1444
|
+
function fmtRatio(r) {
|
|
1445
|
+
if (r == null || !Number.isFinite(r)) return '—';
|
|
1446
|
+
if (r === 1) return '×1.00';
|
|
1447
|
+
const v = r >= 1 ? r : 1 / r; const t = v >= 100 ? Math.round(v).toString() : v >= 10 ? v.toFixed(1) : v.toFixed(2);
|
|
1448
|
+
return (r >= 1 ? '×' : '÷') + t;
|
|
1449
|
+
}
|
|
1450
|
+
// Change of B relative to A: a signed percentage where that reads well, ×N for big increases,
|
|
1451
|
+
// the absolute delta when A is 0 (no ratio) or B is 0 (no "÷∞").
|
|
1452
|
+
function fmtChange(m) {
|
|
1453
|
+
if (m.delta == null) return '—';
|
|
1454
|
+
const r = m.ratio;
|
|
1455
|
+
if (r == null || !m.a || !m.b) return (m.delta > 0 ? '+' : m.delta < 0 ? '−' : '') + fmtMetric(Math.abs(m.delta), m.fmt);
|
|
1456
|
+
if (r === 1) return '0%';
|
|
1457
|
+
if (r < 1) { const p = (1 - r) * 100; return '−' + (p < 10 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%'; }
|
|
1458
|
+
if (r < 10) { const p = (r - 1) * 100; return '+' + (p < 10 ? p.toFixed(1) : Math.round(p)) + '%'; }
|
|
1459
|
+
return fmtRatio(r);
|
|
1460
|
+
}
|
|
1461
|
+
function compare(A, B) {
|
|
1462
|
+
const metrics = METRICS.map((d) => {
|
|
1463
|
+
const a = d.get(A), b = d.get(B);
|
|
1464
|
+
const ok = a != null && b != null && Number.isFinite(a) && Number.isFinite(b);
|
|
1465
|
+
const delta = ok ? b - a : null;
|
|
1466
|
+
const ratio = ok && a ? b / a : (ok && !a && !b ? 1 : null);
|
|
1467
|
+
let better = null;
|
|
1468
|
+
if (ok && d.dir && a !== b) better = (d.dir === 'low') === (a < b) ? 'a' : 'b';
|
|
1469
|
+
return { key: d.key, label: d.label, fmt: d.fmt, dir: d.dir, a, b, delta, ratio, better, aText: fmtMetric(a, d.fmt), bText: fmtMetric(b, d.fmt) };
|
|
1470
|
+
});
|
|
1471
|
+
const ta = toolTable(A.trace), tb = toolTable(B.trace);
|
|
1472
|
+
const names = new Set([...ta.map((t) => t.name), ...tb.map((t) => t.name)]);
|
|
1473
|
+
const zero = { calls: 0, errors: 0, time: 0 };
|
|
1474
|
+
const tools = Array.from(names).map((name) => { const a = ta.find((t) => t.name === name) || zero, b = tb.find((t) => t.name === name) || zero; return { name, a: { calls: a.calls, errors: a.errors, time: a.time }, b: { calls: b.calls, errors: b.errors, time: b.time }, deltaCalls: b.calls - a.calls, deltaTime: b.time - a.time }; });
|
|
1475
|
+
tools.sort((x, y) => Math.abs(y.deltaCalls) - Math.abs(x.deltaCalls) || (y.a.calls + y.b.calls) - (x.a.calls + x.b.calls) || x.name.localeCompare(y.name));
|
|
1476
|
+
const byId = (fs) => { const m = new Map(); for (const f of fs) { if (!m.has(f.id)) m.set(f.id, []); m.get(f.id).push(f); } return m; };
|
|
1477
|
+
const fa = byId(A.findings), fb = byId(B.findings);
|
|
1478
|
+
const slim = (f) => ({ id: f.id, severity: f.severity, title: f.title });
|
|
1479
|
+
const onlyA = [], onlyB = [], both = [];
|
|
1480
|
+
for (const [id, list] of fa) { if (fb.has(id)) both.push({ id, a: list.map(slim), b: fb.get(id).map(slim) }); else onlyA.push(...list.map(slim)); }
|
|
1481
|
+
for (const [id, list] of fb) if (!fa.has(id)) onlyB.push(...list.map(slim));
|
|
1482
|
+
const pick = (key) => { const m = metrics.find((x) => x.key === key); return m ? m.better : null; };
|
|
1483
|
+
const cleaner = (() => { const ea = A.findings.filter((f) => f.severity === 'error').length, eb = B.findings.filter((f) => f.severity === 'error').length; if (ea !== eb) return ea < eb ? 'a' : 'b'; const wa = A.findings.length, wb = B.findings.length; return wa === wb ? null : (wa < wb ? 'a' : 'b'); })();
|
|
1484
|
+
const verdict = { cheaper: pick('cost'), faster: pick('wallMs') || pick('activeMs'), cleaner };
|
|
1485
|
+
return { metrics, tools, findings: { onlyA, onlyB, both }, verdict, a: { sessionId: A.trace.meta.sessionId, title: A.trace.meta.title, models: A.trace.meta.models }, b: { sessionId: B.trace.meta.sessionId, title: B.trace.meta.title, models: B.trace.meta.models } };
|
|
1486
|
+
}
|
|
1487
|
+
function compareMarkdown(c, opts) {
|
|
1488
|
+
opts = opts || {};
|
|
1489
|
+
const la = opts.labelA || 'A', lb = opts.labelB || 'B';
|
|
1490
|
+
const out = [`# Glassbox compare — ${la} vs ${lb}`, ''];
|
|
1491
|
+
out.push(`- ${la}: ${c.a.sessionId || '—'}${c.a.title ? ' · ' + oneLine(c.a.title, 70) : ''}${c.a.models.length ? ' · ' + c.a.models.join(', ') : ''}`);
|
|
1492
|
+
out.push(`- ${lb}: ${c.b.sessionId || '—'}${c.b.title ? ' · ' + oneLine(c.b.title, 70) : ''}${c.b.models.length ? ' · ' + c.b.models.join(', ') : ''}`, '');
|
|
1493
|
+
const v = c.verdict; const who = (x) => x === 'a' ? la : x === 'b' ? lb : 'tie';
|
|
1494
|
+
out.push(`**Verdict** — cheaper: ${who(v.cheaper)} · faster: ${who(v.faster)} · cleaner: ${who(v.cleaner)}`, '');
|
|
1495
|
+
out.push(`| metric | ${la} | ${lb} | change |`, '|---|---|---|---|');
|
|
1496
|
+
for (const m of c.metrics) { const ch = fmtChange(m); out.push(`| ${m.label} | ${m.aText} | ${m.bText} | ${ch}${m.better ? ' (' + who(m.better) + ' better)' : ''} |`); }
|
|
1497
|
+
out.push('');
|
|
1498
|
+
if (c.tools.length) { out.push('## Tools', '', `| tool | ${la} calls (errors) | ${lb} calls (errors) | Δ calls |`, '|---|---|---|---|'); for (const t of c.tools) out.push(`| ${t.name} | ${t.a.calls} (${t.a.errors}) | ${t.b.calls} (${t.b.errors}) | ${t.deltaCalls > 0 ? '+' : ''}${t.deltaCalls} |`); out.push(''); }
|
|
1499
|
+
out.push('## Findings', '');
|
|
1500
|
+
if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) out.push('Neither session was flagged.', '');
|
|
1501
|
+
if (c.findings.onlyA.length) { out.push(`Only in ${la}:`, ''); for (const f of c.findings.onlyA) out.push(`- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}`); out.push(''); }
|
|
1502
|
+
if (c.findings.onlyB.length) { out.push(`Only in ${lb}:`, ''); for (const f of c.findings.onlyB) out.push(`- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}`); out.push(''); }
|
|
1503
|
+
if (c.findings.both.length) { out.push('In both:', ''); for (const f of c.findings.both) out.push(`- \`${f.id}\` — ${la}: ${f.a.length}, ${lb}: ${f.b.length}`); out.push(''); }
|
|
1504
|
+
out.push('_Generated by Glassbox ' + VERSION + '._', '');
|
|
1505
|
+
return out.join('\n');
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1083
1508
|
// ---------------------------------------------------------------------------
|
|
1084
1509
|
// Redaction — keep structure, timestamps, usage, tool names; blank every string.
|
|
1085
1510
|
// ---------------------------------------------------------------------------
|
|
@@ -1108,7 +1533,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1108
1533
|
}
|
|
1109
1534
|
function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
|
|
1110
1535
|
|
|
1111
|
-
return { VERSION, RATES, DEFAULTS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, stableStringify };
|
|
1536
|
+
return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable };
|
|
1112
1537
|
});
|
|
1113
1538
|
</script>
|
|
1114
1539
|
<script>
|
|
@@ -1129,7 +1554,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1129
1554
|
const say = (msg) => { const s = $('#status'); s.textContent = ''; setTimeout(() => { s.textContent = msg; }, 30); };
|
|
1130
1555
|
|
|
1131
1556
|
// ------------------------------------------------------------ state
|
|
1132
|
-
const S = { files: [], records: [], trace: null, findings: [], cost: null, rates: loadRates(), share: false, sel: null, hl: new Set(), toolFilter: null, view: null, query: '', matches: new Set(), focusId: null, lastFocus: null, isDemo: false };
|
|
1557
|
+
const S = { files: [], records: [], trace: null, findings: [], cost: null, rates: loadRates(), share: false, sel: null, hl: new Set(), toolFilter: null, view: null, query: '', matches: new Set(), focusId: null, lastFocus: null, isDemo: false, other: null, labels: { a: 'A', b: 'B' }, cmp: null, follow: true, live: null };
|
|
1558
|
+
const pickingForCompare = () => $('#cmp-dlg').open;
|
|
1133
1559
|
function loadRates() { try { const r = JSON.parse(store.get('rates') || 'null'); if (r && typeof r === 'object') return r; } catch (e) { } return JSON.parse(JSON.stringify(C.RATES)); }
|
|
1134
1560
|
const R = (s) => S.share ? '«' + String(s == null ? '' : s).length + ' chars»' : s;
|
|
1135
1561
|
|
|
@@ -1154,9 +1580,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1154
1580
|
if (!usedEntries) for (const f of Array.from(dt.files || [])) out.push({ name: f.name, file: f });
|
|
1155
1581
|
await loadFiles(out);
|
|
1156
1582
|
}
|
|
1157
|
-
async function loadFiles(list) { const files = []; for (const { name, file } of list) files.push({ name: (file.webkitRelativePath || name), text: await file.text() }); if (files.length)
|
|
1158
|
-
function load(files, isDemo) {
|
|
1583
|
+
async function loadFiles(list, forCompare) { const files = []; for (const { name, file } of list) files.push({ name: (file.webkitRelativePath || name), text: await file.text() }); if (!files.length) { say('No .jsonl files found in what was dropped.'); return; } if (forCompare || pickingForCompare()) loadOther(files); else load(files, false); }
|
|
1584
|
+
function load(files, isDemo, keepOther) {
|
|
1159
1585
|
S.files = files; S.isDemo = !!isDemo;
|
|
1586
|
+
if (!keepOther) { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; }
|
|
1160
1587
|
S.records = files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
|
|
1161
1588
|
S.trace = C.parseTrace(files);
|
|
1162
1589
|
S.findings = C.diagnose(S.trace);
|
|
@@ -1164,6 +1591,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1164
1591
|
S.sel = null; S.hl = new Set(); S.toolFilter = null; S.view = null; S.query = ''; S.matches = new Set(); S.focusId = null; $('#q').value = ''; $('#q-n').textContent = '';
|
|
1165
1592
|
$('#main').hidden = false; $('#banner').hidden = !isDemo;
|
|
1166
1593
|
renderAll();
|
|
1594
|
+
if (S.other) renderCompare();
|
|
1167
1595
|
const T = S.trace.totals;
|
|
1168
1596
|
if (!S.trace.requests.length && !S.trace.toolCalls.length) { const p = $('#problems'); p.hidden = false; p.textContent = 'No API requests or tool calls were found — this does not look like a Claude Code / Agent SDK transcript. Expected a .jsonl with "type":"assistant" and "type":"user" records.'; }
|
|
1169
1597
|
say(`Loaded ${isDemo ? 'the demo session' : files.length + ' file' + (files.length > 1 ? 's' : '')}: ${T.turns} turns, ${T.toolCalls} tool calls, ${S.findings.length} findings${S.trace.problems.length ? ', ' + S.trace.problems.length + ' unreadable lines skipped' : ''}.`);
|
|
@@ -1383,7 +1811,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1383
1811
|
function hideTip() { tip.style.display = 'none'; }
|
|
1384
1812
|
document.addEventListener('mousemove', (e) => { if (tip.style.display === 'block' && !(document.activeElement && document.activeElement.dataset && document.activeElement.dataset.kind)) moveTip(e.clientX, e.clientY); });
|
|
1385
1813
|
function cum(i) { let s = 0; for (let k = 0; k <= i; k++) s += S.cost.byRequest[k] || 0; return s; }
|
|
1386
|
-
|
|
1814
|
+
const inputSummary = C.inputSummary;
|
|
1387
1815
|
|
|
1388
1816
|
// ------------------------------------------------------------ burn chart
|
|
1389
1817
|
function renderBurn() {
|
|
@@ -1423,13 +1851,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1423
1851
|
|
|
1424
1852
|
// ------------------------------------------------------------ findings
|
|
1425
1853
|
function findingMd(f) { return `- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}\n ${f.detail}`; }
|
|
1426
|
-
function reportMd() {
|
|
1427
|
-
const t = S.trace, T = t.totals, m = t.meta;
|
|
1428
|
-
const head = `# Glassbox report — ${R(m.title || 'session')}\n\n` + `- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'} · started: ${m.start ? new Date(m.start).toISOString() : '—'}\n` + `- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed)\n` + `- tokens: ${fmtInt(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} context served (${pct(T.cacheHitRatio)} cached), ${fmtInt(T.usage.output)} output (${fmtInt(T.usage.thinking)} thinking) · est. cost ${S.cost.total != null ? fmtUSD(S.cost.total) : '—'}\n\n`;
|
|
1429
|
-
const fs = S.findings.length ? `## Findings (${S.findings.length})\n\n` + S.findings.map(findingMd).join('\n') + '\n' : '## Findings\n\nNothing flagged.\n';
|
|
1430
|
-
const tools = '\n## Tools\n\n| tool | calls | errors | total time |\n|---|---|---|---|\n' + toolRows().map((r) => `| ${r.name} | ${r.calls} | ${r.errors} | ${fmtDur(r.time)} |`).join('\n') + '\n';
|
|
1431
|
-
return head + fs + tools + '\n_Generated by Glassbox._\n';
|
|
1432
|
-
}
|
|
1854
|
+
function reportMd() { return C.reportMarkdown(S.trace, S.findings, S.cost, { redact: S.share }); }
|
|
1433
1855
|
async function copyText(text, what) { try { await navigator.clipboard.writeText(text); say(what + ' copied to clipboard.'); return true; } catch (e) { const dlg = $('#copy-dlg'); $('#copy-text').value = text; dlg.showModal(); $('#copy-text').select(); say('Clipboard blocked — text shown for manual copy.'); return false; } }
|
|
1434
1856
|
$('#copy-close').onclick = () => $('#copy-dlg').close();
|
|
1435
1857
|
function renderFindings() {
|
|
@@ -1557,6 +1979,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1557
1979
|
function setHash(h) { hashLock = true; try { history.replaceState(null, '', '#' + h); } catch (e) { } setTimeout(() => { hashLock = false; }, 0); }
|
|
1558
1980
|
function applyHash() {
|
|
1559
1981
|
const h = location.hash.slice(1); if (!h || !S.trace) return;
|
|
1982
|
+
if (h === 'compare') { if (S.other) $('#cmp-sec').scrollIntoView(); return; }
|
|
1560
1983
|
const eq = h.indexOf('='); if (eq < 0) return; const k = h.slice(0, eq), v = decodeURIComponent(h.slice(eq + 1)); if (!v) return;
|
|
1561
1984
|
if (k === 'req') { const r = S.trace.requests[+v - 1]; if (r) select({ kind: 'req', ref: r }); }
|
|
1562
1985
|
else if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === v); if (c) select({ kind: 'tool', ref: c }); }
|
|
@@ -1588,7 +2011,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1588
2011
|
const idb = { open() { return new Promise((res, rej) => { const r = indexedDB.open('glassbox', 1); r.onupgradeneeded = () => r.result.createObjectStore('kv'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); },
|
|
1589
2012
|
async get(k) { try { const db = await this.open(); return await new Promise((res) => { const t = db.transaction('kv').objectStore('kv').get(k); t.onsuccess = () => res(t.result); t.onerror = () => res(null); }); } catch (e) { return null; } },
|
|
1590
2013
|
async set(k, v) { try { const db = await this.open(); await new Promise((res) => { const t = db.transaction('kv', 'readwrite').objectStore('kv').put(v, k); t.onsuccess = res; t.onerror = res; }); } catch (e) { } } };
|
|
1591
|
-
const decodeProject = (n) => n.replace(/^-/, '/').replace(/-/g, '/');
|
|
2014
|
+
const decodeProject = (n) => { const m = n.match(/^([A-Za-z])--(.*)$/); if (m) return m[1] + ':/' + m[2].replace(/-/g, '/'); return n.replace(/^-/, '/').replace(/-/g, '/'); };
|
|
1592
2015
|
async function scanProjects(dir) {
|
|
1593
2016
|
const sessions = [];
|
|
1594
2017
|
for await (const [pname, ph] of dir.entries()) {
|
|
@@ -1609,8 +2032,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1609
2032
|
}
|
|
1610
2033
|
async function loadSession(s) {
|
|
1611
2034
|
const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
|
|
1612
|
-
|
|
1613
|
-
|
|
2035
|
+
// subagents/*.jsonl plus Workflow agents in subagents/workflows/<runId>/ (journal.jsonl is the workflow's log)
|
|
2036
|
+
const walk = async (dir, rel) => { for await (const [n, h] of dir.entries()) { if (h.kind === 'directory') await walk(h, rel + '/' + n); else if (/\.(jsonl|json)$/.test(n) && n !== 'journal.jsonl') files.push({ name: rel + '/' + n, text: await (await h.getFile()).text() }); } };
|
|
2037
|
+
try { await walk(await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'), s.id + '/subagents'); } catch (e) { }
|
|
2038
|
+
$('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
|
|
1614
2039
|
}
|
|
1615
2040
|
async function showSessions(dir) {
|
|
1616
2041
|
const dlg = $('#sessions-dlg'); $('#sessions-note').textContent = 'Scanning ' + dir.name + '…'; $('#sessions-tbl').innerHTML = ''; dlg.showModal();
|
|
@@ -1628,6 +2053,99 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1628
2053
|
}
|
|
1629
2054
|
$('#sessions-close').onclick = () => $('#sessions-dlg').close();
|
|
1630
2055
|
|
|
2056
|
+
// ------------------------------------------------------------ compare
|
|
2057
|
+
function analyseFiles(files) { const trace = C.parseTrace(files); return { files, trace, findings: C.diagnose(trace), cost: C.estimateCost(trace, S.rates) }; }
|
|
2058
|
+
function loadOther(files) {
|
|
2059
|
+
if (!S.trace) { say('Load a session first, then compare it with another.'); return; }
|
|
2060
|
+
S.other = analyseFiles(files);
|
|
2061
|
+
try { $('#cmp-dlg').close(); } catch (e) { }
|
|
2062
|
+
renderCompare();
|
|
2063
|
+
const m = S.cmp.metrics.find((x) => x.key === 'cost');
|
|
2064
|
+
say(`Comparing: ${S.labels.a} vs ${S.labels.b}. ${S.other.trace.totals.toolCalls} tool calls in ${S.labels.b}${m && m.better ? ', ' + (m.better === 'a' ? S.labels.a : S.labels.b) + ' is cheaper' : ''}.`);
|
|
2065
|
+
$('#cmp-sec').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
2066
|
+
setHash('compare');
|
|
2067
|
+
}
|
|
2068
|
+
function sessionLabel(res, fallback) { const m = res.trace.meta; return m.title ? m.title.slice(0, 40) : m.sessionId ? m.sessionId.slice(0, 8) : fallback; }
|
|
2069
|
+
function renderCompare() {
|
|
2070
|
+
if (!S.other) return;
|
|
2071
|
+
const A = { trace: S.trace, findings: S.findings, cost: S.cost }, B = S.other;
|
|
2072
|
+
const c = S.cmp = C.compare(A, B);
|
|
2073
|
+
const la = S.labels.a, lb = S.labels.b;
|
|
2074
|
+
const who = (x) => x === 'a' ? `<span class="who a">${esc(la)}</span>` : x === 'b' ? `<span class="who b">${esc(lb)}</span>` : '<span class="who">tie</span>';
|
|
2075
|
+
$('#cmp-aux').innerHTML = `<span class="who a">${esc(la)}</span> ${esc(R(sessionLabel(A, 'this session')))} · <span class="who b">${esc(lb)}</span> ${esc(R(sessionLabel(B, 'other session')))} — ${esc(la)} is on the timeline`;
|
|
2076
|
+
const rows = c.metrics.map((m) => {
|
|
2077
|
+
let bar = '<span class="ch">—</span>';
|
|
2078
|
+
if (m.delta != null) {
|
|
2079
|
+
const good = m.better === 'a' ? 'bad' : m.better === 'b' ? 'good' : '';
|
|
2080
|
+
const ch = C.fmtChange(m);
|
|
2081
|
+
const r = m.ratio != null && m.a ? m.ratio : (m.delta === 0 ? 1 : 2);
|
|
2082
|
+
const f = Math.min(1, Math.abs(Math.log2(Math.max(r, 1e-6))) / 3);
|
|
2083
|
+
const left = r < 1, w = (f * 50).toFixed(1);
|
|
2084
|
+
bar = `<div class="dbar" aria-hidden="true"><div class="track"><div class="fill ${good}" style="left:${left ? 50 - w : 50}%;width:${w}%"></div></div><span class="ch ${good}">${ch}</span></div>`;
|
|
2085
|
+
}
|
|
2086
|
+
const sr = m.delta == null ? 'not comparable' : m.better ? `${m.better === 'a' ? la : lb} better` : 'same';
|
|
2087
|
+
return `<tr><td>${esc(m.label)}</td><td class="n">${m.aText}</td><td class="n">${m.bText}</td><td class="d">${bar}<span class="sr-only">${esc(sr)}</span></td></tr>`;
|
|
2088
|
+
}).join('');
|
|
2089
|
+
const tools = c.tools.slice(0, 14).map((t) => `<tr><td>${esc(t.name)}</td><td class="n">${t.a.calls}${t.a.errors ? ` <span class="errpill">${t.a.errors}</span>` : ''}</td><td class="n">${t.b.calls}${t.b.errors ? ` <span class="errpill">${t.b.errors}</span>` : ''}</td><td class="n">${t.deltaCalls > 0 ? '+' : ''}${t.deltaCalls}</td></tr>`).join('');
|
|
2090
|
+
const fl = (list) => list.length ? `<ul class="fl">${list.map((f) => `<li><span class="sev ${f.severity}"></span> ${esc(f.title)}<span class="id">${f.id}</span></li>`).join('')}</ul>` : '<div class="empty">none</div>';
|
|
2091
|
+
$('#cmp').innerHTML = `<div class="verdict"><span>cheaper ${who(c.verdict.cheaper)}</span><span>faster ${who(c.verdict.faster)}</span><span>cleaner ${who(c.verdict.cleaner)}</span></div>
|
|
2092
|
+
<div class="grid"><div><table><caption class="sr-only">Metrics side by side</caption><thead><tr><th scope="col">Metric</th><th class="n" scope="col">${esc(la)}</th><th class="n" scope="col">${esc(lb)}</th><th scope="col">Change (${esc(lb)} vs ${esc(la)})</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
2093
|
+
<div><h3 style="margin-top:0">Tools</h3><table><caption class="sr-only">Tool calls per session</caption><thead><tr><th scope="col">Tool</th><th class="n" scope="col">${esc(la)}</th><th class="n" scope="col">${esc(lb)}</th><th class="n" scope="col">Δ</th></tr></thead><tbody>${tools || '<tr><td colspan="4" class="empty">no tool calls</td></tr>'}</tbody></table>${c.tools.length > 14 ? `<p class="note">… ${c.tools.length - 14} more tools</p>` : ''}
|
|
2094
|
+
<h3>Only in ${esc(la)}</h3>${fl(c.findings.onlyA)}<h3>Only in ${esc(lb)}</h3>${fl(c.findings.onlyB)}${c.findings.both.length ? `<h3>In both</h3><ul class="fl">${c.findings.both.map((f) => `<li><span class="id" style="margin:0">${f.id}</span> — ${esc(la)}: ${f.a.length}, ${esc(lb)}: ${f.b.length}</li>`).join('')}</ul>` : ''}</div></div>`;
|
|
2095
|
+
$('#cmp-sec').hidden = false;
|
|
2096
|
+
}
|
|
2097
|
+
$('#compare').onclick = () => { if (!S.trace) { say('Load a session first.'); return; } $('#cmp-folder').hidden = !FSA; $('#cmp-dlg').showModal(); };
|
|
2098
|
+
$('#cmp-dlg-close').onclick = () => $('#cmp-dlg').close();
|
|
2099
|
+
$('#cmp-folder').onclick = async () => { $('#cmp-dlg').close(); S.pickOther = true; let h = await idb.get('projects'); try { if (h) { let p = await h.queryPermission({ mode: 'read' }); if (p !== 'granted') p = await h.requestPermission({ mode: 'read' }); if (p !== 'granted') h = null; } if (!h) { h = await window.showDirectoryPicker({ id: 'claude-projects', mode: 'read' }); await idb.set('projects', h); $('#recent').hidden = false; } await showSessions(h); } catch (e) { S.pickOther = false; if (e && e.name !== 'AbortError') say('Could not open the folder: ' + e.message); } };
|
|
2100
|
+
$('#sessions-dlg').addEventListener('close', () => { setTimeout(() => { S.pickOther = false; }, 0); });
|
|
2101
|
+
$('#cmp-pick').onclick = () => $('#file-cmp').click();
|
|
2102
|
+
$('#file-cmp').addEventListener('change', (e) => { loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f })), true); e.target.value = ''; });
|
|
2103
|
+
$('#cmp-demo').onclick = () => { if (!DEMO) { say('No demo embedded in this build.'); return; } loadOther(DEMO.map((f) => ({ name: f.name, text: f.text }))); };
|
|
2104
|
+
$('#cmp-close').onclick = () => { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; setHash(''); say('Compare closed.'); $('#compare').focus(); };
|
|
2105
|
+
$('#cmp-swap').onclick = () => { if (!S.other) return; const cur = { files: S.files, isDemo: S.isDemo }; const nxt = S.other; S.labels = { a: S.labels.b, b: S.labels.a }; S.other = analyseFiles(cur.files); load(nxt.files, false, true); say(`${S.labels.a} is now on the timeline.`); };
|
|
2106
|
+
$('#cmp-copy').onclick = () => { if (!S.cmp) return; copyText(C.compareMarkdown(S.cmp, { labelA: S.labels.a, labelB: S.labels.b }), 'Comparison (Markdown)'); };
|
|
2107
|
+
|
|
2108
|
+
// ------------------------------------------------------------ live tail
|
|
2109
|
+
let liveTimer = null;
|
|
2110
|
+
function applyLiveFiles(ev) {
|
|
2111
|
+
const f = S.files.find((x) => x.name === ev.name);
|
|
2112
|
+
if (ev.type === 'append') { if (f) f.text += ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
|
|
2113
|
+
else if (ev.type === 'replace') { if (f) f.text = ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
|
|
2114
|
+
else if (ev.type === 'hello') { S.files = ev.files.map((x) => ({ name: x.name, text: x.text })); }
|
|
2115
|
+
if (liveTimer) return; liveTimer = setTimeout(() => { liveTimer = null; refresh(); }, 150);
|
|
2116
|
+
}
|
|
2117
|
+
// Re-parse the current files in place: selection, filter, search and zoom survive; Follow keeps the right edge on now.
|
|
2118
|
+
function refresh() {
|
|
2119
|
+
const wasWhole = !S.view || (TL.total && S.view.t1 >= TL.total - 1 && S.view.t0 <= 1);
|
|
2120
|
+
const oldSpan = S.view ? S.view.t1 - S.view.t0 : null;
|
|
2121
|
+
const selId = S.sel ? S.sel.ref.id : null, selKind = S.sel ? S.sel.kind : null;
|
|
2122
|
+
S.records = S.files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
|
|
2123
|
+
S.trace = C.parseTrace(S.files); S.findings = C.diagnose(S.trace); S.cost = C.estimateCost(S.trace, S.rates);
|
|
2124
|
+
if (selId) { const ref = selKind === 'tool' ? S.trace.toolCalls.find((c) => c.id === selId) : S.trace.requests.find((r) => r.id === selId); S.sel = ref ? { kind: selKind, ref } : null; }
|
|
2125
|
+
if (S.toolFilter && !S.trace.toolCalls.some((c) => c.name === S.toolFilter)) S.toolFilter = null;
|
|
2126
|
+
buildLayout();
|
|
2127
|
+
if (S.follow) { if (wasWhole || oldSpan == null || oldSpan >= TL.total) S.view = null; else S.view = { t0: Math.max(0, TL.total - oldSpan), t1: TL.total }; }
|
|
2128
|
+
renderSession(); renderStrip(); renderFit(); renderTimeline(); renderBurn(); renderFindings(); renderTools(); renderTurns();
|
|
2129
|
+
if (S.query) runSearch();
|
|
2130
|
+
if (S.sel && !$('#drawer').hidden) select(S.sel, true);
|
|
2131
|
+
if (S.other) renderCompare();
|
|
2132
|
+
const t = new Date(); $('#live-txt').textContent = 'LIVE · ' + t.toTimeString().slice(0, 8);
|
|
2133
|
+
}
|
|
2134
|
+
function startLive(cfg) {
|
|
2135
|
+
const pill = $('#live'); pill.hidden = false; $('#follow').hidden = false;
|
|
2136
|
+
$('#follow').onclick = () => { S.follow = !S.follow; $('#follow').setAttribute('aria-pressed', String(S.follow)); say(S.follow ? 'Following: the timeline keeps up with the session.' : 'Follow off: the view stays where it is.'); if (S.follow) refresh(); };
|
|
2137
|
+
const es = new EventSource(cfg.url); S.live = es;
|
|
2138
|
+
es.addEventListener('hello', (e) => { pill.classList.remove('off'); $('#live-txt').textContent = 'LIVE'; applyLiveFiles(Object.assign({ type: 'hello' }, JSON.parse(e.data))); });
|
|
2139
|
+
es.addEventListener('append', (e) => applyLiveFiles(JSON.parse(e.data)));
|
|
2140
|
+
es.addEventListener('replace', (e) => applyLiveFiles(JSON.parse(e.data)));
|
|
2141
|
+
es.onerror = () => { pill.classList.add('off'); $('#live-txt').textContent = 'reconnecting'; };
|
|
2142
|
+
// any manual zoom or pan lets go of Follow
|
|
2143
|
+
const letGo = () => { if (S.follow) { S.follow = false; $('#follow').setAttribute('aria-pressed', 'false'); } };
|
|
2144
|
+
$('#tl').addEventListener('wheel', letGo, { passive: true }); $('#tl').addEventListener('pointerdown', letGo);
|
|
2145
|
+
$('#z-in').addEventListener('click', letGo); $('#z-out').addEventListener('click', letGo); $('#fit').addEventListener('change', letGo);
|
|
2146
|
+
$('#banner').hidden = true;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
1631
2149
|
// ------------------------------------------------------------ help + inputs
|
|
1632
2150
|
$('#help').onclick = () => $('#help-dlg').showModal(); $('#help-close').onclick = () => $('#help-dlg').close();
|
|
1633
2151
|
$('#banner-x').onclick = () => { $('#banner').hidden = true; };
|
|
@@ -1637,6 +2155,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1637
2155
|
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); handleDataTransfer(e.dataTransfer); });
|
|
1638
2156
|
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
1639
2157
|
document.addEventListener('drop', (e) => { if (!drop.contains(e.target)) { e.preventDefault(); handleDataTransfer(e.dataTransfer); } });
|
|
2158
|
+
$('#cmp-dlg').addEventListener('dragover', (e) => e.preventDefault()); $('#cmp-dlg').addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); handleDataTransfer(e.dataTransfer); });
|
|
1640
2159
|
$('#file').addEventListener('change', (e) => loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f }))));
|
|
1641
2160
|
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#file').click(); } });
|
|
1642
2161
|
|
|
@@ -1644,8 +2163,14 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1644
2163
|
const EMBED = /*__EMBED__*/null;
|
|
1645
2164
|
function loadDemo(e) { if (e) e.preventDefault(); if (!DEMO) { say('No demo embedded in this build.'); return; } load(DEMO.map((f) => ({ name: f.name, text: f.text })), true); }
|
|
1646
2165
|
$('#demo').onclick = loadDemo;
|
|
1647
|
-
|
|
2166
|
+
const LIVE = /*__LIVE__*/null;
|
|
2167
|
+
const embedFiles = EMBED && !Array.isArray(EMBED) ? EMBED.files : EMBED;
|
|
2168
|
+
if (embedFiles && embedFiles.length) {
|
|
2169
|
+
load(embedFiles.map((f) => ({ name: f.name, text: f.text })), false);
|
|
2170
|
+
if (EMBED && !Array.isArray(EMBED) && EMBED.compare && EMBED.compare.length) { if (EMBED.labels) S.labels = EMBED.labels; loadOther(EMBED.compare.map((f) => ({ name: f.name, text: f.text }))); }
|
|
2171
|
+
}
|
|
1648
2172
|
else if (DEMO) loadDemo();
|
|
2173
|
+
if (LIVE && LIVE.url && 'EventSource' in window) startLive(LIVE);
|
|
1649
2174
|
})();
|
|
1650
2175
|
</script>
|
|
1651
2176
|
|