glassbox-trace 0.3.0 → 0.4.1
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 +32 -13
- package/dist/glassbox.html +380 -25
- package/package.json +5 -2
- package/src/cli.mjs +83 -15
- package/src/tail.mjs +141 -0
- package/src/trace-core.js +217 -9
- package/src/viewer.html +163 -16
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.4.1';
|
|
357
406
|
|
|
358
407
|
// ---------------------------------------------------------------------------
|
|
359
408
|
// Rate card (USD per million tokens). Prefix-matched against model ids so dated
|
|
@@ -410,18 +459,25 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
410
459
|
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
411
460
|
const EXEC_TOOLS = new Set(['Bash', 'BashOutput', 'KillShell']);
|
|
412
461
|
const AGENT_TOOLS = new Set(['Agent', 'Task', 'Workflow']);
|
|
413
|
-
const USER_TOOLS = new Set(['AskUserQuestion']);
|
|
462
|
+
const USER_TOOLS = new Set(['AskUserQuestion', 'SendUserMessage', 'SendUserFile', 'ExitPlanMode', 'EnterPlanMode']);
|
|
463
|
+
// Cowork: Artifact publishes a page (a write), Skill loads instructions (a read); Task* are bookkeeping (other).
|
|
464
|
+
const COWORK_READ = new Set(['Skill', 'ListSkills', 'SearchSkills', 'TaskList', 'TaskGet']);
|
|
465
|
+
const COWORK_WRITE = new Set(['Artifact']);
|
|
466
|
+
// MCP leaf names are verb_noun or noun_verb ("memory_read", "device_list_dir", "send_message"): match the verb as a word.
|
|
467
|
+
const MCP_READ = /(^|_)(get|list|search|read|find|fetch|query|describe|show|stage|screenshot|context|status|info|check)(_|$)/i;
|
|
468
|
+
const MCP_WRITE = /(^|_)(write|set|put|create|update|delete|remove|commit|send|reply|forward|upload|str_replace|append|label|move|rename|save)(_|$)/i;
|
|
414
469
|
|
|
415
470
|
function toolCategory(name) {
|
|
416
471
|
if (!name) return 'other';
|
|
417
|
-
if (READ_TOOLS.has(name)) return 'read';
|
|
418
|
-
if (WRITE_TOOLS.has(name)) return 'write';
|
|
472
|
+
if (READ_TOOLS.has(name) || COWORK_READ.has(name)) return 'read';
|
|
473
|
+
if (WRITE_TOOLS.has(name) || COWORK_WRITE.has(name)) return 'write';
|
|
419
474
|
if (EXEC_TOOLS.has(name) || /device_bash$/.test(name)) return 'exec';
|
|
420
475
|
if (AGENT_TOOLS.has(name)) return 'agent';
|
|
421
476
|
if (USER_TOOLS.has(name)) return 'user';
|
|
422
477
|
if (name.startsWith('mcp__')) {
|
|
423
478
|
const leaf = name.split('__').pop() || '';
|
|
424
|
-
if (
|
|
479
|
+
if (MCP_READ.test(leaf)) return 'read';
|
|
480
|
+
if (MCP_WRITE.test(leaf)) return 'write';
|
|
425
481
|
return 'mcp';
|
|
426
482
|
}
|
|
427
483
|
return 'other';
|
|
@@ -577,7 +633,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
577
633
|
const meta = {
|
|
578
634
|
sessionId: null, version: null, cwd: null, gitBranch: null, entrypoint: null, title: null,
|
|
579
635
|
models: [], start: null, end: null, hasTimestamps, files: fileMeta, format: 'claude-code',
|
|
580
|
-
reportedCost: null, reportedUsage: null, recordCounts: {},
|
|
636
|
+
reportedCost: null, reportedUsage: null, reportedTurns: null, reportedDurationMs: null, recordCounts: {},
|
|
581
637
|
};
|
|
582
638
|
|
|
583
639
|
const agents = new Map(); // id -> agent
|
|
@@ -644,6 +700,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
644
700
|
if (type === 'result') {
|
|
645
701
|
meta.format = 'stream-json';
|
|
646
702
|
if (typeof rec.total_cost_usd === 'number') meta.reportedCost = rec.total_cost_usd;
|
|
703
|
+
if (typeof rec.num_turns === 'number') meta.reportedTurns = rec.num_turns;
|
|
704
|
+
if (typeof rec.duration_ms === 'number') meta.reportedDurationMs = rec.duration_ms;
|
|
647
705
|
if (rec.usage) meta.reportedUsage = usageFrom(rec.usage);
|
|
648
706
|
if (rec.is_error) events.push({ kind: 'api_error', at: t, detail: textOf(rec.result) || 'result is_error' });
|
|
649
707
|
continue;
|
|
@@ -809,12 +867,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
809
867
|
const usage = emptyUsage();
|
|
810
868
|
for (const r of requests) addUsage(usage, r.usage);
|
|
811
869
|
const humanIdleMs = turns.reduce((s, tn) => s + (tn.idleBeforeMs || 0), 0);
|
|
812
|
-
|
|
870
|
+
// stream-json runs carry no per-record timestamps and no human prompt record, but the result
|
|
871
|
+
// record reports duration_ms and num_turns; use those rather than showing "—" and 0.
|
|
872
|
+
const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : (meta.reportedDurationMs != null ? meta.reportedDurationMs : null);
|
|
873
|
+
const humanTurns = turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length;
|
|
813
874
|
const totals = {
|
|
814
875
|
usage, wallMs, humanIdleMs, activeMs: wallMs != null ? Math.max(0, wallMs - humanIdleMs) : null,
|
|
815
876
|
requests: requests.length, toolCalls: toolCalls.length, toolErrors: toolCalls.filter((c) => c.isError).length,
|
|
816
877
|
orphans: toolCalls.filter((c) => c.status === 'orphan').length,
|
|
817
|
-
turns:
|
|
878
|
+
turns: humanTurns || (meta.reportedTurns != null ? meta.reportedTurns : 0),
|
|
818
879
|
toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
|
|
819
880
|
modelTimeMs: agents.get('main').modelTimeMs || 0,
|
|
820
881
|
cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
|
|
@@ -1080,6 +1141,202 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1080
1141
|
};
|
|
1081
1142
|
}
|
|
1082
1143
|
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
// Advice — one sentence per rule on what to do differently. Mechanical, like the rules.
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
const ADVICE = {
|
|
1148
|
+
'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.',
|
|
1149
|
+
'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.',
|
|
1150
|
+
'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.',
|
|
1151
|
+
'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.',
|
|
1152
|
+
'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.',
|
|
1153
|
+
'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.',
|
|
1154
|
+
'context-bloat': 'Keep the prompt small: summarise long results into notes, avoid re-reading files already in context, and hand large investigations to subagents.',
|
|
1155
|
+
'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.',
|
|
1156
|
+
'low-cache-hit': 'Long stable prefixes cache well; frequent tool-list or system-prompt changes and many tiny sessions do not.',
|
|
1157
|
+
'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.',
|
|
1158
|
+
'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.',
|
|
1159
|
+
'long-generation': 'Large single outputs are normal for file writes; split very large files into parts if a stall (low tok/s) shows up.',
|
|
1160
|
+
'max-tokens': 'The output was truncated: write large files in sections and keep single responses under the output limit.',
|
|
1161
|
+
'api-error': 'An API error interrupted the session; check what was lost and re-run the affected step rather than assuming it completed.',
|
|
1162
|
+
'hook-error': 'A hook failed; read the hook error, fix the hook command or its permissions, then re-run the step it guarded.',
|
|
1163
|
+
'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.',
|
|
1164
|
+
'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.',
|
|
1165
|
+
'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
|
|
1166
|
+
'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
// Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
|
|
1170
|
+
function adviceFor(f, trace) {
|
|
1171
|
+
if (f.id === 'slow-tool') {
|
|
1172
|
+
const ids = (f.evidence && f.evidence.toolCallIds) || [];
|
|
1173
|
+
const c = trace.toolCalls.find((x) => x.id === ids[0]);
|
|
1174
|
+
if (c && c.category === 'user') return null; // waiting on the human is not the agent's time
|
|
1175
|
+
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.';
|
|
1176
|
+
}
|
|
1177
|
+
return ADVICE[f.id] || null;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function inputSummary(c) {
|
|
1181
|
+
const i = (c && c.input) || {};
|
|
1182
|
+
if (i.command) return String(i.command);
|
|
1183
|
+
if (i.file_path) return String(i.file_path) + (i.pattern ? ' ' + i.pattern : '');
|
|
1184
|
+
if (i.pattern) return String(i.pattern);
|
|
1185
|
+
if (i.query) return String(i.query);
|
|
1186
|
+
if (i.description) return String(i.description);
|
|
1187
|
+
if (i.url) return String(i.url);
|
|
1188
|
+
if (i.prompt) return String(i.prompt);
|
|
1189
|
+
if (i.path) return String(i.path);
|
|
1190
|
+
const s = JSON.stringify(i);
|
|
1191
|
+
return s === '{}' ? '' : s;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
// Markdown report — written to be handed back to the agent as well as read by a human.
|
|
1196
|
+
// opts: { redact, advice=true, instruction=true, maxEvidence=6, title }
|
|
1197
|
+
// ---------------------------------------------------------------------------
|
|
1198
|
+
function usd(v) { return v == null ? '—' : v < 0.01 ? '$' + v.toFixed(4) : v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2); }
|
|
1199
|
+
function pctStr(x) { return x == null ? '—' : Math.round(x * 100) + '%'; }
|
|
1200
|
+
function oneLine(s, n) { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
|
1201
|
+
function blank(s) { return '«' + String(s == null ? '' : s).length + ' chars»'; }
|
|
1202
|
+
|
|
1203
|
+
function toolTable(trace) {
|
|
1204
|
+
const rows = new Map();
|
|
1205
|
+
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; }
|
|
1206
|
+
return Array.from(rows.values()).sort((a, b) => b.time - a.time || b.calls - a.calls);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Rules whose detail quotes tool output or model text (everything else is names and numbers).
|
|
1210
|
+
const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error']);
|
|
1211
|
+
function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
|
|
1212
|
+
|
|
1213
|
+
function reportMarkdown(trace, findings, cost, opts) {
|
|
1214
|
+
opts = opts || {};
|
|
1215
|
+
const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
|
|
1216
|
+
const T = trace.totals, m = trace.meta, u = T.usage;
|
|
1217
|
+
const R = (s) => redact ? blank(s) : s;
|
|
1218
|
+
const total = cost ? (cost.reported != null ? cost.reported : cost.total) : null;
|
|
1219
|
+
const turnNo = (idx) => idx != null ? idx + 1 : null; // same numbering as the finding details and the viewer drawer
|
|
1220
|
+
const advised = new Set();
|
|
1221
|
+
const out = [];
|
|
1222
|
+
out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
|
|
1223
|
+
out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
|
|
1224
|
+
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' : ''})`);
|
|
1225
|
+
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)' : ''}`, '');
|
|
1226
|
+
if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
|
|
1227
|
+
else {
|
|
1228
|
+
out.push(`## Findings (${findings.length})`, '');
|
|
1229
|
+
findings.forEach((f, i) => {
|
|
1230
|
+
out.push(`### ${i + 1}. ${f.severity.toUpperCase()} \`${f.id}\` — ${f.title}`, '', redact ? redactDetail(f) : f.detail, '');
|
|
1231
|
+
const ev = f.evidence || {};
|
|
1232
|
+
const lines = [];
|
|
1233
|
+
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)'}\``); }
|
|
1234
|
+
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) : ''}`); }
|
|
1235
|
+
if (lines.length) { out.push('Evidence:', ''); out.push(...lines.slice(0, maxEv)); if (lines.length > maxEv) out.push(`- … ${lines.length - maxEv} more`); out.push(''); }
|
|
1236
|
+
const adv = opts.advice !== false && !advised.has(f.id) ? adviceFor(f, trace) : null;
|
|
1237
|
+
if (adv) { advised.add(f.id); out.push(`Next time: ${adv}`, ''); }
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
const tools = toolTable(trace);
|
|
1241
|
+
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(''); }
|
|
1242
|
+
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.', '');
|
|
1243
|
+
out.push('_Generated by Glassbox ' + VERSION + '._', '');
|
|
1244
|
+
return out.join('\n');
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ---------------------------------------------------------------------------
|
|
1248
|
+
// Compare two analysed sessions ({trace, findings, cost}).
|
|
1249
|
+
// ---------------------------------------------------------------------------
|
|
1250
|
+
const METRICS = [
|
|
1251
|
+
{ key: 'wallMs', label: 'Wall time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.wallMs },
|
|
1252
|
+
{ key: 'activeMs', label: 'Active time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.activeMs },
|
|
1253
|
+
{ key: 'turns', label: 'Turns', fmt: 'int', dir: null, get: (s) => s.trace.totals.turns },
|
|
1254
|
+
{ key: 'requests', label: 'API requests', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.requests },
|
|
1255
|
+
{ key: 'toolCalls', label: 'Tool calls', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolCalls },
|
|
1256
|
+
{ key: 'toolErrors', label: 'Tool errors', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolErrors },
|
|
1257
|
+
{ key: 'toolTimeMs', label: 'Time in tools', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.toolTimeMs : null },
|
|
1258
|
+
{ key: 'modelTimeMs', label: 'Time generating', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.modelTimeMs : null },
|
|
1259
|
+
{ 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); } },
|
|
1260
|
+
{ 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 },
|
|
1261
|
+
{ key: 'cacheHitRatio', label: 'Cache hit', fmt: 'pct', dir: 'high', get: (s) => s.trace.totals.cacheHitRatio },
|
|
1262
|
+
{ key: 'output', label: 'Output tokens', fmt: 'tok', dir: 'low', get: (s) => s.trace.requests.length ? s.trace.totals.usage.output : null },
|
|
1263
|
+
{ 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; } },
|
|
1264
|
+
{ key: 'cost', label: 'Est. cost', fmt: 'usd', dir: 'low', get: (s) => s.cost ? (s.cost.reported != null ? s.cost.reported : s.cost.total) : null },
|
|
1265
|
+
{ key: 'findings', label: 'Findings', fmt: 'int', dir: 'low', get: (s) => s.findings.length },
|
|
1266
|
+
{ key: 'errorFindings', label: 'Error-level findings', fmt: 'int', dir: 'low', get: (s) => s.findings.filter((f) => f.severity === 'error').length },
|
|
1267
|
+
];
|
|
1268
|
+
function fmtMetric(v, fmt) {
|
|
1269
|
+
if (v == null) return '—';
|
|
1270
|
+
if (fmt === 'dur') return fmtDur(v);
|
|
1271
|
+
if (fmt === 'pct') return pctStr(v);
|
|
1272
|
+
if (fmt === 'usd') return usd(v);
|
|
1273
|
+
return fmtInt(v);
|
|
1274
|
+
}
|
|
1275
|
+
// "×2.4" when B is bigger, "÷2.4" when smaller; never "×0.00".
|
|
1276
|
+
function fmtRatio(r) {
|
|
1277
|
+
if (r == null || !Number.isFinite(r)) return '—';
|
|
1278
|
+
if (r === 1) return '×1.00';
|
|
1279
|
+
const v = r >= 1 ? r : 1 / r; const t = v >= 100 ? Math.round(v).toString() : v >= 10 ? v.toFixed(1) : v.toFixed(2);
|
|
1280
|
+
return (r >= 1 ? '×' : '÷') + t;
|
|
1281
|
+
}
|
|
1282
|
+
// Change of B relative to A: a signed percentage where that reads well, ×N for big increases,
|
|
1283
|
+
// the absolute delta when A is 0 (no ratio) or B is 0 (no "÷∞").
|
|
1284
|
+
function fmtChange(m) {
|
|
1285
|
+
if (m.delta == null) return '—';
|
|
1286
|
+
const r = m.ratio;
|
|
1287
|
+
if (r == null || !m.a || !m.b) return (m.delta > 0 ? '+' : m.delta < 0 ? '−' : '') + fmtMetric(Math.abs(m.delta), m.fmt);
|
|
1288
|
+
if (r === 1) return '0%';
|
|
1289
|
+
if (r < 1) { const p = (1 - r) * 100; return '−' + (p < 10 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%'; }
|
|
1290
|
+
if (r < 10) { const p = (r - 1) * 100; return '+' + (p < 10 ? p.toFixed(1) : Math.round(p)) + '%'; }
|
|
1291
|
+
return fmtRatio(r);
|
|
1292
|
+
}
|
|
1293
|
+
function compare(A, B) {
|
|
1294
|
+
const metrics = METRICS.map((d) => {
|
|
1295
|
+
const a = d.get(A), b = d.get(B);
|
|
1296
|
+
const ok = a != null && b != null && Number.isFinite(a) && Number.isFinite(b);
|
|
1297
|
+
const delta = ok ? b - a : null;
|
|
1298
|
+
const ratio = ok && a ? b / a : (ok && !a && !b ? 1 : null);
|
|
1299
|
+
let better = null;
|
|
1300
|
+
if (ok && d.dir && a !== b) better = (d.dir === 'low') === (a < b) ? 'a' : 'b';
|
|
1301
|
+
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) };
|
|
1302
|
+
});
|
|
1303
|
+
const ta = toolTable(A.trace), tb = toolTable(B.trace);
|
|
1304
|
+
const names = new Set([...ta.map((t) => t.name), ...tb.map((t) => t.name)]);
|
|
1305
|
+
const zero = { calls: 0, errors: 0, time: 0 };
|
|
1306
|
+
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 }; });
|
|
1307
|
+
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));
|
|
1308
|
+
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; };
|
|
1309
|
+
const fa = byId(A.findings), fb = byId(B.findings);
|
|
1310
|
+
const slim = (f) => ({ id: f.id, severity: f.severity, title: f.title });
|
|
1311
|
+
const onlyA = [], onlyB = [], both = [];
|
|
1312
|
+
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)); }
|
|
1313
|
+
for (const [id, list] of fb) if (!fa.has(id)) onlyB.push(...list.map(slim));
|
|
1314
|
+
const pick = (key) => { const m = metrics.find((x) => x.key === key); return m ? m.better : null; };
|
|
1315
|
+
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'); })();
|
|
1316
|
+
const verdict = { cheaper: pick('cost'), faster: pick('wallMs') || pick('activeMs'), cleaner };
|
|
1317
|
+
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 } };
|
|
1318
|
+
}
|
|
1319
|
+
function compareMarkdown(c, opts) {
|
|
1320
|
+
opts = opts || {};
|
|
1321
|
+
const la = opts.labelA || 'A', lb = opts.labelB || 'B';
|
|
1322
|
+
const out = [`# Glassbox compare — ${la} vs ${lb}`, ''];
|
|
1323
|
+
out.push(`- ${la}: ${c.a.sessionId || '—'}${c.a.title ? ' · ' + oneLine(c.a.title, 70) : ''}${c.a.models.length ? ' · ' + c.a.models.join(', ') : ''}`);
|
|
1324
|
+
out.push(`- ${lb}: ${c.b.sessionId || '—'}${c.b.title ? ' · ' + oneLine(c.b.title, 70) : ''}${c.b.models.length ? ' · ' + c.b.models.join(', ') : ''}`, '');
|
|
1325
|
+
const v = c.verdict; const who = (x) => x === 'a' ? la : x === 'b' ? lb : 'tie';
|
|
1326
|
+
out.push(`**Verdict** — cheaper: ${who(v.cheaper)} · faster: ${who(v.faster)} · cleaner: ${who(v.cleaner)}`, '');
|
|
1327
|
+
out.push(`| metric | ${la} | ${lb} | change |`, '|---|---|---|---|');
|
|
1328
|
+
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)' : ''} |`); }
|
|
1329
|
+
out.push('');
|
|
1330
|
+
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(''); }
|
|
1331
|
+
out.push('## Findings', '');
|
|
1332
|
+
if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) out.push('Neither session was flagged.', '');
|
|
1333
|
+
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(''); }
|
|
1334
|
+
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(''); }
|
|
1335
|
+
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(''); }
|
|
1336
|
+
out.push('_Generated by Glassbox ' + VERSION + '._', '');
|
|
1337
|
+
return out.join('\n');
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1083
1340
|
// ---------------------------------------------------------------------------
|
|
1084
1341
|
// Redaction — keep structure, timestamps, usage, tool names; blank every string.
|
|
1085
1342
|
// ---------------------------------------------------------------------------
|
|
@@ -1108,7 +1365,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1108
1365
|
}
|
|
1109
1366
|
function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
|
|
1110
1367
|
|
|
1111
|
-
return { VERSION, RATES, DEFAULTS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, stableStringify };
|
|
1368
|
+
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
1369
|
});
|
|
1113
1370
|
</script>
|
|
1114
1371
|
<script>
|
|
@@ -1129,7 +1386,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1129
1386
|
const say = (msg) => { const s = $('#status'); s.textContent = ''; setTimeout(() => { s.textContent = msg; }, 30); };
|
|
1130
1387
|
|
|
1131
1388
|
// ------------------------------------------------------------ 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 };
|
|
1389
|
+
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 };
|
|
1390
|
+
const pickingForCompare = () => $('#cmp-dlg').open;
|
|
1133
1391
|
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
1392
|
const R = (s) => S.share ? '«' + String(s == null ? '' : s).length + ' chars»' : s;
|
|
1135
1393
|
|
|
@@ -1154,9 +1412,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1154
1412
|
if (!usedEntries) for (const f of Array.from(dt.files || [])) out.push({ name: f.name, file: f });
|
|
1155
1413
|
await loadFiles(out);
|
|
1156
1414
|
}
|
|
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) {
|
|
1415
|
+
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); }
|
|
1416
|
+
function load(files, isDemo, keepOther) {
|
|
1159
1417
|
S.files = files; S.isDemo = !!isDemo;
|
|
1418
|
+
if (!keepOther) { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; }
|
|
1160
1419
|
S.records = files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
|
|
1161
1420
|
S.trace = C.parseTrace(files);
|
|
1162
1421
|
S.findings = C.diagnose(S.trace);
|
|
@@ -1164,6 +1423,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1164
1423
|
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
1424
|
$('#main').hidden = false; $('#banner').hidden = !isDemo;
|
|
1166
1425
|
renderAll();
|
|
1426
|
+
if (S.other) renderCompare();
|
|
1167
1427
|
const T = S.trace.totals;
|
|
1168
1428
|
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
1429
|
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 +1643,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1383
1643
|
function hideTip() { tip.style.display = 'none'; }
|
|
1384
1644
|
document.addEventListener('mousemove', (e) => { if (tip.style.display === 'block' && !(document.activeElement && document.activeElement.dataset && document.activeElement.dataset.kind)) moveTip(e.clientX, e.clientY); });
|
|
1385
1645
|
function cum(i) { let s = 0; for (let k = 0; k <= i; k++) s += S.cost.byRequest[k] || 0; return s; }
|
|
1386
|
-
|
|
1646
|
+
const inputSummary = C.inputSummary;
|
|
1387
1647
|
|
|
1388
1648
|
// ------------------------------------------------------------ burn chart
|
|
1389
1649
|
function renderBurn() {
|
|
@@ -1423,13 +1683,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1423
1683
|
|
|
1424
1684
|
// ------------------------------------------------------------ findings
|
|
1425
1685
|
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
|
-
}
|
|
1686
|
+
function reportMd() { return C.reportMarkdown(S.trace, S.findings, S.cost, { redact: S.share }); }
|
|
1433
1687
|
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
1688
|
$('#copy-close').onclick = () => $('#copy-dlg').close();
|
|
1435
1689
|
function renderFindings() {
|
|
@@ -1557,6 +1811,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1557
1811
|
function setHash(h) { hashLock = true; try { history.replaceState(null, '', '#' + h); } catch (e) { } setTimeout(() => { hashLock = false; }, 0); }
|
|
1558
1812
|
function applyHash() {
|
|
1559
1813
|
const h = location.hash.slice(1); if (!h || !S.trace) return;
|
|
1814
|
+
if (h === 'compare') { if (S.other) $('#cmp-sec').scrollIntoView(); return; }
|
|
1560
1815
|
const eq = h.indexOf('='); if (eq < 0) return; const k = h.slice(0, eq), v = decodeURIComponent(h.slice(eq + 1)); if (!v) return;
|
|
1561
1816
|
if (k === 'req') { const r = S.trace.requests[+v - 1]; if (r) select({ kind: 'req', ref: r }); }
|
|
1562
1817
|
else if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === v); if (c) select({ kind: 'tool', ref: c }); }
|
|
@@ -1588,7 +1843,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1588
1843
|
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
1844
|
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
1845
|
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, '/');
|
|
1846
|
+
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
1847
|
async function scanProjects(dir) {
|
|
1593
1848
|
const sessions = [];
|
|
1594
1849
|
for await (const [pname, ph] of dir.entries()) {
|
|
@@ -1610,7 +1865,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1610
1865
|
async function loadSession(s) {
|
|
1611
1866
|
const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
|
|
1612
1867
|
try { const sdir = await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'); for await (const [n, h] of sdir.entries()) if (h.kind === 'file' && /\.(jsonl|json)$/.test(n)) files.push({ name: s.id + '/subagents/' + n, text: await (await h.getFile()).text() }); } catch (e) { }
|
|
1613
|
-
$('#sessions-dlg').close(); load(files, false);
|
|
1868
|
+
$('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
|
|
1614
1869
|
}
|
|
1615
1870
|
async function showSessions(dir) {
|
|
1616
1871
|
const dlg = $('#sessions-dlg'); $('#sessions-note').textContent = 'Scanning ' + dir.name + '…'; $('#sessions-tbl').innerHTML = ''; dlg.showModal();
|
|
@@ -1628,6 +1883,99 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1628
1883
|
}
|
|
1629
1884
|
$('#sessions-close').onclick = () => $('#sessions-dlg').close();
|
|
1630
1885
|
|
|
1886
|
+
// ------------------------------------------------------------ compare
|
|
1887
|
+
function analyseFiles(files) { const trace = C.parseTrace(files); return { files, trace, findings: C.diagnose(trace), cost: C.estimateCost(trace, S.rates) }; }
|
|
1888
|
+
function loadOther(files) {
|
|
1889
|
+
if (!S.trace) { say('Load a session first, then compare it with another.'); return; }
|
|
1890
|
+
S.other = analyseFiles(files);
|
|
1891
|
+
try { $('#cmp-dlg').close(); } catch (e) { }
|
|
1892
|
+
renderCompare();
|
|
1893
|
+
const m = S.cmp.metrics.find((x) => x.key === 'cost');
|
|
1894
|
+
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' : ''}.`);
|
|
1895
|
+
$('#cmp-sec').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1896
|
+
setHash('compare');
|
|
1897
|
+
}
|
|
1898
|
+
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; }
|
|
1899
|
+
function renderCompare() {
|
|
1900
|
+
if (!S.other) return;
|
|
1901
|
+
const A = { trace: S.trace, findings: S.findings, cost: S.cost }, B = S.other;
|
|
1902
|
+
const c = S.cmp = C.compare(A, B);
|
|
1903
|
+
const la = S.labels.a, lb = S.labels.b;
|
|
1904
|
+
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>';
|
|
1905
|
+
$('#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`;
|
|
1906
|
+
const rows = c.metrics.map((m) => {
|
|
1907
|
+
let bar = '<span class="ch">—</span>';
|
|
1908
|
+
if (m.delta != null) {
|
|
1909
|
+
const good = m.better === 'a' ? 'bad' : m.better === 'b' ? 'good' : '';
|
|
1910
|
+
const ch = C.fmtChange(m);
|
|
1911
|
+
const r = m.ratio != null && m.a ? m.ratio : (m.delta === 0 ? 1 : 2);
|
|
1912
|
+
const f = Math.min(1, Math.abs(Math.log2(Math.max(r, 1e-6))) / 3);
|
|
1913
|
+
const left = r < 1, w = (f * 50).toFixed(1);
|
|
1914
|
+
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>`;
|
|
1915
|
+
}
|
|
1916
|
+
const sr = m.delta == null ? 'not comparable' : m.better ? `${m.better === 'a' ? la : lb} better` : 'same';
|
|
1917
|
+
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>`;
|
|
1918
|
+
}).join('');
|
|
1919
|
+
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('');
|
|
1920
|
+
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>';
|
|
1921
|
+
$('#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>
|
|
1922
|
+
<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>
|
|
1923
|
+
<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>` : ''}
|
|
1924
|
+
<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>`;
|
|
1925
|
+
$('#cmp-sec').hidden = false;
|
|
1926
|
+
}
|
|
1927
|
+
$('#compare').onclick = () => { if (!S.trace) { say('Load a session first.'); return; } $('#cmp-folder').hidden = !FSA; $('#cmp-dlg').showModal(); };
|
|
1928
|
+
$('#cmp-dlg-close').onclick = () => $('#cmp-dlg').close();
|
|
1929
|
+
$('#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); } };
|
|
1930
|
+
$('#sessions-dlg').addEventListener('close', () => { setTimeout(() => { S.pickOther = false; }, 0); });
|
|
1931
|
+
$('#cmp-pick').onclick = () => $('#file-cmp').click();
|
|
1932
|
+
$('#file-cmp').addEventListener('change', (e) => { loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f })), true); e.target.value = ''; });
|
|
1933
|
+
$('#cmp-demo').onclick = () => { if (!DEMO) { say('No demo embedded in this build.'); return; } loadOther(DEMO.map((f) => ({ name: f.name, text: f.text }))); };
|
|
1934
|
+
$('#cmp-close').onclick = () => { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; setHash(''); say('Compare closed.'); $('#compare').focus(); };
|
|
1935
|
+
$('#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.`); };
|
|
1936
|
+
$('#cmp-copy').onclick = () => { if (!S.cmp) return; copyText(C.compareMarkdown(S.cmp, { labelA: S.labels.a, labelB: S.labels.b }), 'Comparison (Markdown)'); };
|
|
1937
|
+
|
|
1938
|
+
// ------------------------------------------------------------ live tail
|
|
1939
|
+
let liveTimer = null;
|
|
1940
|
+
function applyLiveFiles(ev) {
|
|
1941
|
+
const f = S.files.find((x) => x.name === ev.name);
|
|
1942
|
+
if (ev.type === 'append') { if (f) f.text += ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
|
|
1943
|
+
else if (ev.type === 'replace') { if (f) f.text = ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
|
|
1944
|
+
else if (ev.type === 'hello') { S.files = ev.files.map((x) => ({ name: x.name, text: x.text })); }
|
|
1945
|
+
if (liveTimer) return; liveTimer = setTimeout(() => { liveTimer = null; refresh(); }, 150);
|
|
1946
|
+
}
|
|
1947
|
+
// Re-parse the current files in place: selection, filter, search and zoom survive; Follow keeps the right edge on now.
|
|
1948
|
+
function refresh() {
|
|
1949
|
+
const wasWhole = !S.view || (TL.total && S.view.t1 >= TL.total - 1 && S.view.t0 <= 1);
|
|
1950
|
+
const oldSpan = S.view ? S.view.t1 - S.view.t0 : null;
|
|
1951
|
+
const selId = S.sel ? S.sel.ref.id : null, selKind = S.sel ? S.sel.kind : null;
|
|
1952
|
+
S.records = S.files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
|
|
1953
|
+
S.trace = C.parseTrace(S.files); S.findings = C.diagnose(S.trace); S.cost = C.estimateCost(S.trace, S.rates);
|
|
1954
|
+
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; }
|
|
1955
|
+
if (S.toolFilter && !S.trace.toolCalls.some((c) => c.name === S.toolFilter)) S.toolFilter = null;
|
|
1956
|
+
buildLayout();
|
|
1957
|
+
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 }; }
|
|
1958
|
+
renderSession(); renderStrip(); renderFit(); renderTimeline(); renderBurn(); renderFindings(); renderTools(); renderTurns();
|
|
1959
|
+
if (S.query) runSearch();
|
|
1960
|
+
if (S.sel && !$('#drawer').hidden) select(S.sel, true);
|
|
1961
|
+
if (S.other) renderCompare();
|
|
1962
|
+
const t = new Date(); $('#live-txt').textContent = 'LIVE · ' + t.toTimeString().slice(0, 8);
|
|
1963
|
+
}
|
|
1964
|
+
function startLive(cfg) {
|
|
1965
|
+
const pill = $('#live'); pill.hidden = false; $('#follow').hidden = false;
|
|
1966
|
+
$('#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(); };
|
|
1967
|
+
const es = new EventSource(cfg.url); S.live = es;
|
|
1968
|
+
es.addEventListener('hello', (e) => { pill.classList.remove('off'); $('#live-txt').textContent = 'LIVE'; applyLiveFiles(Object.assign({ type: 'hello' }, JSON.parse(e.data))); });
|
|
1969
|
+
es.addEventListener('append', (e) => applyLiveFiles(JSON.parse(e.data)));
|
|
1970
|
+
es.addEventListener('replace', (e) => applyLiveFiles(JSON.parse(e.data)));
|
|
1971
|
+
es.onerror = () => { pill.classList.add('off'); $('#live-txt').textContent = 'reconnecting'; };
|
|
1972
|
+
// any manual zoom or pan lets go of Follow
|
|
1973
|
+
const letGo = () => { if (S.follow) { S.follow = false; $('#follow').setAttribute('aria-pressed', 'false'); } };
|
|
1974
|
+
$('#tl').addEventListener('wheel', letGo, { passive: true }); $('#tl').addEventListener('pointerdown', letGo);
|
|
1975
|
+
$('#z-in').addEventListener('click', letGo); $('#z-out').addEventListener('click', letGo); $('#fit').addEventListener('change', letGo);
|
|
1976
|
+
$('#banner').hidden = true;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1631
1979
|
// ------------------------------------------------------------ help + inputs
|
|
1632
1980
|
$('#help').onclick = () => $('#help-dlg').showModal(); $('#help-close').onclick = () => $('#help-dlg').close();
|
|
1633
1981
|
$('#banner-x').onclick = () => { $('#banner').hidden = true; };
|
|
@@ -1637,6 +1985,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1637
1985
|
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); handleDataTransfer(e.dataTransfer); });
|
|
1638
1986
|
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
1639
1987
|
document.addEventListener('drop', (e) => { if (!drop.contains(e.target)) { e.preventDefault(); handleDataTransfer(e.dataTransfer); } });
|
|
1988
|
+
$('#cmp-dlg').addEventListener('dragover', (e) => e.preventDefault()); $('#cmp-dlg').addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); handleDataTransfer(e.dataTransfer); });
|
|
1640
1989
|
$('#file').addEventListener('change', (e) => loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f }))));
|
|
1641
1990
|
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#file').click(); } });
|
|
1642
1991
|
|
|
@@ -1644,8 +1993,14 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1644
1993
|
const EMBED = /*__EMBED__*/null;
|
|
1645
1994
|
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
1995
|
$('#demo').onclick = loadDemo;
|
|
1647
|
-
|
|
1996
|
+
const LIVE = /*__LIVE__*/null;
|
|
1997
|
+
const embedFiles = EMBED && !Array.isArray(EMBED) ? EMBED.files : EMBED;
|
|
1998
|
+
if (embedFiles && embedFiles.length) {
|
|
1999
|
+
load(embedFiles.map((f) => ({ name: f.name, text: f.text })), false);
|
|
2000
|
+
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 }))); }
|
|
2001
|
+
}
|
|
1648
2002
|
else if (DEMO) loadDemo();
|
|
2003
|
+
if (LIVE && LIVE.url && 'EventSource' in window) startLive(LIVE);
|
|
1649
2004
|
})();
|
|
1650
2005
|
</script>
|
|
1651
2006
|
|