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/src/trace-core.js CHANGED
@@ -9,7 +9,7 @@
9
9
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
10
10
  'use strict';
11
11
 
12
- const VERSION = '0.3.0';
12
+ const VERSION = '0.4.1';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -66,18 +66,25 @@
66
66
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
67
67
  const EXEC_TOOLS = new Set(['Bash', 'BashOutput', 'KillShell']);
68
68
  const AGENT_TOOLS = new Set(['Agent', 'Task', 'Workflow']);
69
- const USER_TOOLS = new Set(['AskUserQuestion']);
69
+ const USER_TOOLS = new Set(['AskUserQuestion', 'SendUserMessage', 'SendUserFile', 'ExitPlanMode', 'EnterPlanMode']);
70
+ // Cowork: Artifact publishes a page (a write), Skill loads instructions (a read); Task* are bookkeeping (other).
71
+ const COWORK_READ = new Set(['Skill', 'ListSkills', 'SearchSkills', 'TaskList', 'TaskGet']);
72
+ const COWORK_WRITE = new Set(['Artifact']);
73
+ // MCP leaf names are verb_noun or noun_verb ("memory_read", "device_list_dir", "send_message"): match the verb as a word.
74
+ const MCP_READ = /(^|_)(get|list|search|read|find|fetch|query|describe|show|stage|screenshot|context|status|info|check)(_|$)/i;
75
+ const MCP_WRITE = /(^|_)(write|set|put|create|update|delete|remove|commit|send|reply|forward|upload|str_replace|append|label|move|rename|save)(_|$)/i;
70
76
 
71
77
  function toolCategory(name) {
72
78
  if (!name) return 'other';
73
- if (READ_TOOLS.has(name)) return 'read';
74
- if (WRITE_TOOLS.has(name)) return 'write';
79
+ if (READ_TOOLS.has(name) || COWORK_READ.has(name)) return 'read';
80
+ if (WRITE_TOOLS.has(name) || COWORK_WRITE.has(name)) return 'write';
75
81
  if (EXEC_TOOLS.has(name) || /device_bash$/.test(name)) return 'exec';
76
82
  if (AGENT_TOOLS.has(name)) return 'agent';
77
83
  if (USER_TOOLS.has(name)) return 'user';
78
84
  if (name.startsWith('mcp__')) {
79
85
  const leaf = name.split('__').pop() || '';
80
- if (/^(get|list|search|read|find|fetch|query|describe|show)/i.test(leaf)) return 'read';
86
+ if (MCP_READ.test(leaf)) return 'read';
87
+ if (MCP_WRITE.test(leaf)) return 'write';
81
88
  return 'mcp';
82
89
  }
83
90
  return 'other';
@@ -233,7 +240,7 @@
233
240
  const meta = {
234
241
  sessionId: null, version: null, cwd: null, gitBranch: null, entrypoint: null, title: null,
235
242
  models: [], start: null, end: null, hasTimestamps, files: fileMeta, format: 'claude-code',
236
- reportedCost: null, reportedUsage: null, recordCounts: {},
243
+ reportedCost: null, reportedUsage: null, reportedTurns: null, reportedDurationMs: null, recordCounts: {},
237
244
  };
238
245
 
239
246
  const agents = new Map(); // id -> agent
@@ -300,6 +307,8 @@
300
307
  if (type === 'result') {
301
308
  meta.format = 'stream-json';
302
309
  if (typeof rec.total_cost_usd === 'number') meta.reportedCost = rec.total_cost_usd;
310
+ if (typeof rec.num_turns === 'number') meta.reportedTurns = rec.num_turns;
311
+ if (typeof rec.duration_ms === 'number') meta.reportedDurationMs = rec.duration_ms;
303
312
  if (rec.usage) meta.reportedUsage = usageFrom(rec.usage);
304
313
  if (rec.is_error) events.push({ kind: 'api_error', at: t, detail: textOf(rec.result) || 'result is_error' });
305
314
  continue;
@@ -465,12 +474,15 @@
465
474
  const usage = emptyUsage();
466
475
  for (const r of requests) addUsage(usage, r.usage);
467
476
  const humanIdleMs = turns.reduce((s, tn) => s + (tn.idleBeforeMs || 0), 0);
468
- const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : null;
477
+ // stream-json runs carry no per-record timestamps and no human prompt record, but the result
478
+ // record reports duration_ms and num_turns; use those rather than showing "—" and 0.
479
+ const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : (meta.reportedDurationMs != null ? meta.reportedDurationMs : null);
480
+ const humanTurns = turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length;
469
481
  const totals = {
470
482
  usage, wallMs, humanIdleMs, activeMs: wallMs != null ? Math.max(0, wallMs - humanIdleMs) : null,
471
483
  requests: requests.length, toolCalls: toolCalls.length, toolErrors: toolCalls.filter((c) => c.isError).length,
472
484
  orphans: toolCalls.filter((c) => c.status === 'orphan').length,
473
- turns: turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length,
485
+ turns: humanTurns || (meta.reportedTurns != null ? meta.reportedTurns : 0),
474
486
  toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
475
487
  modelTimeMs: agents.get('main').modelTimeMs || 0,
476
488
  cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
@@ -736,6 +748,202 @@
736
748
  };
737
749
  }
738
750
 
751
+ // ---------------------------------------------------------------------------
752
+ // Advice — one sentence per rule on what to do differently. Mechanical, like the rules.
753
+ // ---------------------------------------------------------------------------
754
+ const ADVICE = {
755
+ '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.',
756
+ '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.',
757
+ '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.',
758
+ '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.',
759
+ '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.',
760
+ '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.',
761
+ 'context-bloat': 'Keep the prompt small: summarise long results into notes, avoid re-reading files already in context, and hand large investigations to subagents.',
762
+ '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.',
763
+ 'low-cache-hit': 'Long stable prefixes cache well; frequent tool-list or system-prompt changes and many tiny sessions do not.',
764
+ '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.',
765
+ '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.',
766
+ 'long-generation': 'Large single outputs are normal for file writes; split very large files into parts if a stall (low tok/s) shows up.',
767
+ 'max-tokens': 'The output was truncated: write large files in sections and keep single responses under the output limit.',
768
+ 'api-error': 'An API error interrupted the session; check what was lost and re-run the affected step rather than assuming it completed.',
769
+ 'hook-error': 'A hook failed; read the hook error, fix the hook command or its permissions, then re-run the step it guarded.',
770
+ '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.',
771
+ '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.',
772
+ 'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
773
+ 'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
774
+ };
775
+
776
+ // Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
777
+ function adviceFor(f, trace) {
778
+ if (f.id === 'slow-tool') {
779
+ const ids = (f.evidence && f.evidence.toolCallIds) || [];
780
+ const c = trace.toolCalls.find((x) => x.id === ids[0]);
781
+ if (c && c.category === 'user') return null; // waiting on the human is not the agent's time
782
+ 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.';
783
+ }
784
+ return ADVICE[f.id] || null;
785
+ }
786
+
787
+ function inputSummary(c) {
788
+ const i = (c && c.input) || {};
789
+ if (i.command) return String(i.command);
790
+ if (i.file_path) return String(i.file_path) + (i.pattern ? ' ' + i.pattern : '');
791
+ if (i.pattern) return String(i.pattern);
792
+ if (i.query) return String(i.query);
793
+ if (i.description) return String(i.description);
794
+ if (i.url) return String(i.url);
795
+ if (i.prompt) return String(i.prompt);
796
+ if (i.path) return String(i.path);
797
+ const s = JSON.stringify(i);
798
+ return s === '{}' ? '' : s;
799
+ }
800
+
801
+ // ---------------------------------------------------------------------------
802
+ // Markdown report — written to be handed back to the agent as well as read by a human.
803
+ // opts: { redact, advice=true, instruction=true, maxEvidence=6, title }
804
+ // ---------------------------------------------------------------------------
805
+ function usd(v) { return v == null ? '—' : v < 0.01 ? '$' + v.toFixed(4) : v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2); }
806
+ function pctStr(x) { return x == null ? '—' : Math.round(x * 100) + '%'; }
807
+ function oneLine(s, n) { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
808
+ function blank(s) { return '«' + String(s == null ? '' : s).length + ' chars»'; }
809
+
810
+ function toolTable(trace) {
811
+ const rows = new Map();
812
+ 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; }
813
+ return Array.from(rows.values()).sort((a, b) => b.time - a.time || b.calls - a.calls);
814
+ }
815
+
816
+ // Rules whose detail quotes tool output or model text (everything else is names and numbers).
817
+ const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error']);
818
+ function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
819
+
820
+ function reportMarkdown(trace, findings, cost, opts) {
821
+ opts = opts || {};
822
+ const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
823
+ const T = trace.totals, m = trace.meta, u = T.usage;
824
+ const R = (s) => redact ? blank(s) : s;
825
+ const total = cost ? (cost.reported != null ? cost.reported : cost.total) : null;
826
+ const turnNo = (idx) => idx != null ? idx + 1 : null; // same numbering as the finding details and the viewer drawer
827
+ const advised = new Set();
828
+ const out = [];
829
+ out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
830
+ out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
831
+ 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' : ''})`);
832
+ 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)' : ''}`, '');
833
+ if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
834
+ else {
835
+ out.push(`## Findings (${findings.length})`, '');
836
+ findings.forEach((f, i) => {
837
+ out.push(`### ${i + 1}. ${f.severity.toUpperCase()} \`${f.id}\` — ${f.title}`, '', redact ? redactDetail(f) : f.detail, '');
838
+ const ev = f.evidence || {};
839
+ const lines = [];
840
+ 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)'}\``); }
841
+ 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) : ''}`); }
842
+ if (lines.length) { out.push('Evidence:', ''); out.push(...lines.slice(0, maxEv)); if (lines.length > maxEv) out.push(`- … ${lines.length - maxEv} more`); out.push(''); }
843
+ const adv = opts.advice !== false && !advised.has(f.id) ? adviceFor(f, trace) : null;
844
+ if (adv) { advised.add(f.id); out.push(`Next time: ${adv}`, ''); }
845
+ });
846
+ }
847
+ const tools = toolTable(trace);
848
+ 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(''); }
849
+ 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.', '');
850
+ out.push('_Generated by Glassbox ' + VERSION + '._', '');
851
+ return out.join('\n');
852
+ }
853
+
854
+ // ---------------------------------------------------------------------------
855
+ // Compare two analysed sessions ({trace, findings, cost}).
856
+ // ---------------------------------------------------------------------------
857
+ const METRICS = [
858
+ { key: 'wallMs', label: 'Wall time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.wallMs },
859
+ { key: 'activeMs', label: 'Active time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.activeMs },
860
+ { key: 'turns', label: 'Turns', fmt: 'int', dir: null, get: (s) => s.trace.totals.turns },
861
+ { key: 'requests', label: 'API requests', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.requests },
862
+ { key: 'toolCalls', label: 'Tool calls', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolCalls },
863
+ { key: 'toolErrors', label: 'Tool errors', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolErrors },
864
+ { key: 'toolTimeMs', label: 'Time in tools', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.toolTimeMs : null },
865
+ { key: 'modelTimeMs', label: 'Time generating', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.modelTimeMs : null },
866
+ { 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); } },
867
+ { 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 },
868
+ { key: 'cacheHitRatio', label: 'Cache hit', fmt: 'pct', dir: 'high', get: (s) => s.trace.totals.cacheHitRatio },
869
+ { key: 'output', label: 'Output tokens', fmt: 'tok', dir: 'low', get: (s) => s.trace.requests.length ? s.trace.totals.usage.output : null },
870
+ { 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; } },
871
+ { key: 'cost', label: 'Est. cost', fmt: 'usd', dir: 'low', get: (s) => s.cost ? (s.cost.reported != null ? s.cost.reported : s.cost.total) : null },
872
+ { key: 'findings', label: 'Findings', fmt: 'int', dir: 'low', get: (s) => s.findings.length },
873
+ { key: 'errorFindings', label: 'Error-level findings', fmt: 'int', dir: 'low', get: (s) => s.findings.filter((f) => f.severity === 'error').length },
874
+ ];
875
+ function fmtMetric(v, fmt) {
876
+ if (v == null) return '—';
877
+ if (fmt === 'dur') return fmtDur(v);
878
+ if (fmt === 'pct') return pctStr(v);
879
+ if (fmt === 'usd') return usd(v);
880
+ return fmtInt(v);
881
+ }
882
+ // "×2.4" when B is bigger, "÷2.4" when smaller; never "×0.00".
883
+ function fmtRatio(r) {
884
+ if (r == null || !Number.isFinite(r)) return '—';
885
+ if (r === 1) return '×1.00';
886
+ const v = r >= 1 ? r : 1 / r; const t = v >= 100 ? Math.round(v).toString() : v >= 10 ? v.toFixed(1) : v.toFixed(2);
887
+ return (r >= 1 ? '×' : '÷') + t;
888
+ }
889
+ // Change of B relative to A: a signed percentage where that reads well, ×N for big increases,
890
+ // the absolute delta when A is 0 (no ratio) or B is 0 (no "÷∞").
891
+ function fmtChange(m) {
892
+ if (m.delta == null) return '—';
893
+ const r = m.ratio;
894
+ if (r == null || !m.a || !m.b) return (m.delta > 0 ? '+' : m.delta < 0 ? '−' : '') + fmtMetric(Math.abs(m.delta), m.fmt);
895
+ if (r === 1) return '0%';
896
+ if (r < 1) { const p = (1 - r) * 100; return '−' + (p < 10 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%'; }
897
+ if (r < 10) { const p = (r - 1) * 100; return '+' + (p < 10 ? p.toFixed(1) : Math.round(p)) + '%'; }
898
+ return fmtRatio(r);
899
+ }
900
+ function compare(A, B) {
901
+ const metrics = METRICS.map((d) => {
902
+ const a = d.get(A), b = d.get(B);
903
+ const ok = a != null && b != null && Number.isFinite(a) && Number.isFinite(b);
904
+ const delta = ok ? b - a : null;
905
+ const ratio = ok && a ? b / a : (ok && !a && !b ? 1 : null);
906
+ let better = null;
907
+ if (ok && d.dir && a !== b) better = (d.dir === 'low') === (a < b) ? 'a' : 'b';
908
+ 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) };
909
+ });
910
+ const ta = toolTable(A.trace), tb = toolTable(B.trace);
911
+ const names = new Set([...ta.map((t) => t.name), ...tb.map((t) => t.name)]);
912
+ const zero = { calls: 0, errors: 0, time: 0 };
913
+ 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 }; });
914
+ 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));
915
+ 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; };
916
+ const fa = byId(A.findings), fb = byId(B.findings);
917
+ const slim = (f) => ({ id: f.id, severity: f.severity, title: f.title });
918
+ const onlyA = [], onlyB = [], both = [];
919
+ 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)); }
920
+ for (const [id, list] of fb) if (!fa.has(id)) onlyB.push(...list.map(slim));
921
+ const pick = (key) => { const m = metrics.find((x) => x.key === key); return m ? m.better : null; };
922
+ 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'); })();
923
+ const verdict = { cheaper: pick('cost'), faster: pick('wallMs') || pick('activeMs'), cleaner };
924
+ 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 } };
925
+ }
926
+ function compareMarkdown(c, opts) {
927
+ opts = opts || {};
928
+ const la = opts.labelA || 'A', lb = opts.labelB || 'B';
929
+ const out = [`# Glassbox compare — ${la} vs ${lb}`, ''];
930
+ out.push(`- ${la}: ${c.a.sessionId || '—'}${c.a.title ? ' · ' + oneLine(c.a.title, 70) : ''}${c.a.models.length ? ' · ' + c.a.models.join(', ') : ''}`);
931
+ out.push(`- ${lb}: ${c.b.sessionId || '—'}${c.b.title ? ' · ' + oneLine(c.b.title, 70) : ''}${c.b.models.length ? ' · ' + c.b.models.join(', ') : ''}`, '');
932
+ const v = c.verdict; const who = (x) => x === 'a' ? la : x === 'b' ? lb : 'tie';
933
+ out.push(`**Verdict** — cheaper: ${who(v.cheaper)} · faster: ${who(v.faster)} · cleaner: ${who(v.cleaner)}`, '');
934
+ out.push(`| metric | ${la} | ${lb} | change |`, '|---|---|---|---|');
935
+ 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)' : ''} |`); }
936
+ out.push('');
937
+ 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(''); }
938
+ out.push('## Findings', '');
939
+ if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) out.push('Neither session was flagged.', '');
940
+ 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(''); }
941
+ 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(''); }
942
+ 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(''); }
943
+ out.push('_Generated by Glassbox ' + VERSION + '._', '');
944
+ return out.join('\n');
945
+ }
946
+
739
947
  // ---------------------------------------------------------------------------
740
948
  // Redaction — keep structure, timestamps, usage, tool names; blank every string.
741
949
  // ---------------------------------------------------------------------------
@@ -764,5 +972,5 @@
764
972
  }
765
973
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
766
974
 
767
- return { VERSION, RATES, DEFAULTS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, stableStringify };
975
+ 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 };
768
976
  });