glassbox-trace 0.5.0 → 0.6.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 CHANGED
@@ -31,6 +31,9 @@ glassbox open 81c4 --out trace.html write a self-contained HTML you can send t
31
31
  glassbox check print findings; exit 1 on any error-level finding
32
32
  glassbox check --format md the findings as Markdown written for the agent: evidence + what to do next time
33
33
  glassbox check --fail-on warn --format json stricter, machine-readable (CI, hooks, agents); add --redact before sharing
34
+ glassbox check --all --since 30d --redact --legend audit.legend.json --format json
35
+ every session, counts only, files as keys; the legend (key → path) stays with you
36
+ glassbox reveal report.md --legend audit.legend.json turn the keys in a report back into paths, on your machine
34
37
  glassbox compare 81c4 9f0a same task, two sessions: time, tokens, cost, tools and findings side by side
35
38
  glassbox compare 81c4 9f0a --out cmp.html …as one HTML with both sessions in it
36
39
  glassbox watch live tail: the viewer follows the newest session as Claude Code writes it
@@ -38,7 +41,7 @@ glassbox watch live tail: the viewer follows the newest s
38
41
 
39
42
  The npm package is `glassbox-trace` (plain `glassbox` was already taken); the command it installs is `glassbox`. Subagent transcripts next to the session are included automatically. `GLASSBOX_HOME` overrides `~/.claude`; `GLASSBOX_BROWSER` names the command used to open HTML.
40
43
 
41
- **In CI:** exit 0 clean, 1 on findings at/above `--fail-on`, 2 on a usage error; `--format json` carries `glassbox` (version) and `schema`; `--redact` blanks prompt text, tool inputs and quoted output. A GitHub Actions example for Agent SDK runs is in [docs/CI.md](docs/CI.md).
44
+ **In CI:** exit 0 clean, 1 on findings at/above `--fail-on`, 2 on a usage error; `--format json` carries `glassbox` (version) and `schema` (2 since 0.6.0: adds `summary.files` and `evidence.files` when a legend is used; schema-1 readers can ignore them); `--redact` blanks prompt text, tool inputs and quoted output. A GitHub Actions example for Agent SDK runs is in [docs/CI.md](docs/CI.md).
42
45
 
43
46
  **Feed it back to Claude.** `glassbox check --format md` prints the findings the way an agent needs them: each one with the concrete tool calls and request numbers it is about, and a fixed "next time" line per rule. Paste it into the next session ("here's what went wrong last time"), pipe it into a file, or let the Stop hook below deliver it automatically.
44
47
 
@@ -62,6 +65,8 @@ glassbox hook uninstall
62
65
 
63
66
  **In the browser:** the viewer is one HTML file — open `dist/glassbox.html` (double-click, no server; the CLI, compare-from-terminal and live tail need Node 18+). It shows the demo session at rest. Then either drop your `.jsonl` (or the whole `<session-id>` folder for subagent lanes), or in Chrome/Edge click **Open folder…**, pick `~/.claude/projects`, and choose a session from the list — the folder is remembered, so next time it's **Recent**. Finding the file by hand: `ls -t ~/.claude/projects/*/*.jsonl | head` on macOS/Linux, `%USERPROFILE%\.claude\projects\` on Windows.
64
67
 
68
+ **Cowork sessions that run in the cloud** keep their transcript in the cloud workspace, not on your machine, and it goes away when the session ends. Before you finish, ask Claude to copy it to a folder you've connected ("save this session's transcript to my Glassbox folder"); it can read its own `~/.claude/projects/…/<session-id>.jsonl` and write it out. Cowork sessions that run locally, and all Claude Code sessions, are already where `glassbox list` looks.
69
+
65
70
  Read top to bottom: stats → timeline (with minimap, search, fit-to-turn) → context & cost → findings → tools → turns. Click anything for detail. Every selection is a permalink (`#req=17`, `#tool=…`, `#find=3`, `#turn=2`).
66
71
 
67
72
  **Keyboard:** `Tab` into the timeline, `←`/`→` previous/next call, `↑`/`↓` change lane, `Enter` opens detail, `Esc` closes it; `+` `−` `0` zoom, `Shift+←/→` pan, `/` search, `?` help. Touch: drag to pan, pinch to zoom.
@@ -148,6 +153,10 @@ console.log(compare({ trace, findings, cost }, other)); // other = the same
148
153
 
149
154
  ## Privacy
150
155
 
151
- The page makes no network requests except the Google Fonts stylesheet (it falls back to system fonts if that's blocked). `glassbox watch` binds to `127.0.0.1` only and stops with the command. Transcripts contain everything the agent saw; that's why share mode exists.
156
+ Your context is private — by construction, not by promise.
157
+
158
+ - **Nothing leaves.** The page makes no network requests except the Google Fonts stylesheet (it falls back to system fonts if that's blocked). The CLI sends nothing anywhere. `glassbox watch` binds to `127.0.0.1` only and stops with the command.
159
+ - **Counts, not text.** Transcripts contain everything the agent saw; that's why share mode and `--redact` exist. Redacted output keeps tool names, numbers, timings and cost and drops every string — prompts, commands, results, titles. Where a finding would quote something it says `«240 chars»`.
160
+ - **The shape, not the names.** A redacted report still needs to say "this one file was read 48 times and 6 of those failed". `check --redact --legend audit.legend.json` replaces every path with a keyed hash (`file:b94b1a35`, HMAC-SHA256 with a random salt) and writes the key → path map to the legend file, which stays on your machine. The redacted JSON carries `summary.files` (reads, writes, failures, agents, chars per key) and `evidence.files` on findings; `duplicate-subagent-read` keeps its sentence with the key in it. `glassbox reveal report.md --legend audit.legend.json` turns the keys back into paths for you. Re-using the legend keeps keys stable across runs; two machines with two legends produce different keys for the same file. Treat the legend like a password file: local, git-ignored, never attached to the same email as the report. Design notes in DESIGN.md §12.
152
161
 
153
162
  Source: [github.com/aldohushi1-stack/glassbox](https://github.com/aldohushi1-stack/glassbox) · MIT © Aldo Hushi
@@ -402,7 +402,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
402
402
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
403
403
  'use strict';
404
404
 
405
- const VERSION = '0.5.0';
405
+ const VERSION = '0.6.0';
406
406
 
407
407
  // ---------------------------------------------------------------------------
408
408
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -1378,6 +1378,41 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1378
1378
  const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read']);
1379
1379
  function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
1380
1380
 
1381
+ // ---------------------------------------------------------------------------
1382
+ // Per-file use: how often each file was read or written, by how many agents, and how often
1383
+ // those calls failed. Paths are normalised (backslashes, case) but otherwise kept; the CLI's
1384
+ // legend replaces them with keys when sharing.
1385
+ // ---------------------------------------------------------------------------
1386
+ const FILE_READ = new Set(['Read']);
1387
+ const FILE_WRITE = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
1388
+ function filePathOf(c) { const i = c && c.input; if (!i) return null; const p = i.file_path || i.notebook_path; return typeof p === 'string' && p ? p : null; }
1389
+ function normalisePath(p) { return String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); }
1390
+ function fileStats(trace, opts) {
1391
+ const minCalls = (opts && opts.minCalls) || 3, minAgents = (opts && opts.minAgents) || 2;
1392
+ const rows = new Map();
1393
+ for (const c of trace.toolCalls) {
1394
+ if (c.unmatched) continue;
1395
+ const isRead = FILE_READ.has(c.name), isWrite = FILE_WRITE.has(c.name);
1396
+ if (!isRead && !isWrite) continue;
1397
+ const p = filePathOf(c); if (!p) continue;
1398
+ const k = normalisePath(p);
1399
+ if (!rows.has(k)) rows.set(k, { path: p, reads: 0, writes: 0, errors: 0, chars: 0, agentSet: new Set(), tools: {} });
1400
+ const r = rows.get(k);
1401
+ if (isRead) { r.reads++; r.chars += c.resultChars || 0; } else r.writes++;
1402
+ if (c.isError) r.errors++;
1403
+ r.agentSet.add(c.agent);
1404
+ r.tools[c.name] = (r.tools[c.name] || 0) + 1;
1405
+ }
1406
+ const out = [];
1407
+ for (const r of rows.values()) {
1408
+ const calls = r.reads + r.writes;
1409
+ if (calls < minCalls && !r.errors && r.agentSet.size < minAgents) continue;
1410
+ out.push({ path: r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agentSet.size, chars: r.chars, tools: r.tools });
1411
+ }
1412
+ out.sort((a, b) => (b.reads + b.writes) - (a.reads + a.writes) || b.errors - a.errors || a.path.localeCompare(b.path));
1413
+ return out;
1414
+ }
1415
+
1381
1416
  function reportMarkdown(trace, findings, cost, opts) {
1382
1417
  opts = opts || {};
1383
1418
  const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
@@ -1533,7 +1568,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1533
1568
  }
1534
1569
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1535
1570
 
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 };
1571
+ 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, fileStats, filePathOf, normalisePath };
1537
1572
  });
1538
1573
  </script>
1539
1574
  <script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glassbox-trace",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
5
  "main": "src/trace-core.js",
6
6
  "scripts": {
@@ -44,7 +44,7 @@
44
44
  "accessibility"
45
45
  ],
46
46
  "devDependencies": {
47
- "playwright": "^1.49.0"
47
+ "playwright": "1.63.0"
48
48
  },
49
49
  "homepage": "https://github.com/aldohushi1-stack/glassbox#readme",
50
50
  "repository": {
package/src/cli.mjs CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
+ import crypto from 'node:crypto';
6
7
  import { createRequire } from 'node:module';
7
8
  import { fileURLToPath } from 'node:url';
8
9
  const require = createRequire(import.meta.url);
@@ -125,6 +126,34 @@ export function openInBrowser(file, env = process.env) {
125
126
  child.on('error', () => { }); child.unref();
126
127
  }
127
128
 
129
+ // ---------------------------------------------------------------------------
130
+ // Legend: file paths → stable keys ("file:1a2b3c4d") for redacted output. The key is the first
131
+ // 8 hex chars of HMAC-SHA256(salt, normalised path); the salt is random, made once, and lives
132
+ // only in the legend file with the key→path map. Re-using the file keeps keys stable across runs.
133
+ // ---------------------------------------------------------------------------
134
+ export const FILE_KEY_RE = /\bfile:[0-9a-f]{8,16}\b/g;
135
+ export class Legend {
136
+ constructor(data) { this.salt = data && data.salt || crypto.randomBytes(32).toString('hex'); this.files = data && data.files || {}; this.created = data && data.created || new Date().toISOString(); this.byPath = new Map(Object.entries(this.files).map(([k, v]) => [core.normalisePath(v), k])); this.dirty = !data; }
137
+ static load(file) {
138
+ if (!file || !fs.existsSync(file)) return new Legend(null);
139
+ let json; try { json = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`--legend ${file}: not valid JSON (delete it to start a new legend)`); }
140
+ if (!json || typeof json.salt !== 'string' || typeof json.files !== 'object') throw new Error(`--legend ${file}: not a Glassbox legend`);
141
+ return new Legend(json);
142
+ }
143
+ keyFor(p) {
144
+ const norm = core.normalisePath(p);
145
+ const have = this.byPath.get(norm); if (have) return have;
146
+ const hex = crypto.createHmac('sha256', Buffer.from(this.salt, 'hex')).update(norm).digest('hex');
147
+ let len = 8; let key = 'file:' + hex.slice(0, len);
148
+ while (this.files[key] && core.normalisePath(this.files[key]) !== norm && len < 16) { len += 2; key = 'file:' + hex.slice(0, len); } // collision: lengthen
149
+ this.files[key] = String(p); this.byPath.set(norm, key); this.dirty = true;
150
+ return key;
151
+ }
152
+ pathFor(key) { return this.files[key] || null; }
153
+ save(file) { if (!file) return null; fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ glassbox: core.VERSION, note: 'Glassbox legend: maps file keys in a redacted report back to paths on this machine. Keep it here; never send it with the report.', created: this.created, updated: new Date().toISOString(), salt: this.salt, files: this.files }, null, 2) + '\n'); this.dirty = false; return path.resolve(file); }
154
+ reveal(text) { let unknown = 0; const out = String(text).replace(FILE_KEY_RE, (k) => { const p = this.pathFor(k); if (p == null) unknown++; return p == null ? k : p; }); return { text: out, unknown }; }
155
+ }
156
+
128
157
  const SEV = { error: 0, warn: 1, info: 2 };
129
158
  // Text output only: identical findings (same severity, rule and title) print once with a ×N count.
130
159
  export function collapseFindings(findings) {
@@ -141,8 +170,22 @@ export function checkReport({ trace, findings, cost }, opts = {}) {
141
170
  const fmt = core.fmtDur, fi = core.fmtInt;
142
171
  const usd = (v) => v == null ? '—' : '$' + (v < 1 ? v.toFixed(3) : v.toFixed(2));
143
172
  const redact = !!opts.redact;
173
+ const legend = opts.legend || null;
174
+ const keyed = (p) => legend ? legend.keyFor(p) : p;
144
175
  const summary = { session: m.sessionId, title: m.title ? (redact ? '«' + m.title.length + ' chars»' : m.title) : null, model: m.models, wallMs: T.wallMs, activeMs: T.activeMs, turns: T.turns, requests: T.requests, toolCalls: T.toolCalls, toolErrors: T.toolErrors, orphans: T.orphans, usage: T.usage, cacheHitRatio: T.cacheHitRatio, cost: cost.reported != null ? cost.reported : cost.total, costSource: cost.source };
145
- const json = { glassbox: core.VERSION, schema: 1, redacted: redact, summary, findings: findings.map((f) => ({ id: f.id, severity: f.severity, title: f.title, detail: redact ? core.redactDetail(f) : f.detail, metric: f.metric, evidence: f.evidence })), failOn, failed: failing.length > 0 };
176
+ // Per-file use, keyed when a legend is given; with --redact and no legend the paths would leak, so it is omitted.
177
+ if (legend || !redact) summary.files = core.fileStats(trace).map((r) => ({ key: legend ? legend.keyFor(r.path) : r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agents, chars: r.chars }));
178
+ const findingJson = (f) => {
179
+ const ev = f.evidence || {};
180
+ const paths = Array.from(new Set((ev.toolCallIds || []).map((id) => { const c = trace.toolCalls.find((x) => x.id === id); return c ? core.filePathOf(c) : null; }).filter(Boolean)));
181
+ let detail = redact ? core.redactDetail(f) : f.detail;
182
+ // duplicate-subagent-read quotes the path and nothing else: with a legend, keep the sentence and key the path.
183
+ if (redact && legend && f.id === 'duplicate-subagent-read') { detail = f.detail; for (const p of paths) detail = detail.split(p).join(legend.keyFor(p)); }
184
+ const evidence = Object.assign({}, ev);
185
+ if (paths.length && (legend || !redact)) evidence.files = Array.from(new Set(paths.map(keyed)));
186
+ return { id: f.id, severity: f.severity, title: f.title, detail, metric: f.metric, evidence };
187
+ };
188
+ const json = { glassbox: core.VERSION, schema: 2, redacted: redact, legend: !!legend, summary, findings: findings.map(findingJson), failOn, failed: failing.length > 0 };
146
189
  const lines = [];
147
190
  lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
148
191
  lines.push(` wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
@@ -281,7 +324,7 @@ export function parseArgs(argv) {
281
324
  const args = { _: [], flags: {} };
282
325
  for (let i = 0; i < argv.length; i++) {
283
326
  const a = argv[i];
284
- if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
327
+ if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
285
328
  else args._.push(a);
286
329
  }
287
330
  return args;
@@ -300,6 +343,11 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
300
343
  --redact blanks prompt text, tool inputs and result text so the report can be shared
301
344
  glassbox check --all [--since 1h] [--project PATH]
302
345
  one line per session (all, or written in the last 30m/1h/2d); exit 1 if any fails
346
+ glassbox check --redact --legend FILE
347
+ keep the shape of file use in redacted output: every path becomes a key (file:1a2b3c4d)
348
+ and FILE gets the key→path map — it stays with you and never goes with the report
349
+ glassbox reveal FILE --legend LEGEND
350
+ print FILE (a report, JSON, any text) with the keys turned back into paths
303
351
  glassbox compare A B [--format text|json|md] [--out FILE.html] [--label-a NAME --label-b NAME]
304
352
  same task, two sessions: what changed in time, tokens, cost, tools and findings
305
353
  glassbox watch [ID|FILE] [--port N] [--no-open]
@@ -357,14 +405,27 @@ export async function main(argv, io = {}) {
357
405
  for (const s of list) out(`${s.id.slice(0, 8)} ${new Date(s.mtime).toISOString().slice(0, 16).replace('T', ' ')} ${String(Math.round(s.size / 1024)).padStart(6)} KB ${s.project.padEnd(28).slice(0, 28)} ${sessionTitle(s.file)}`);
358
406
  return 0;
359
407
  }
408
+ const legendFile = args.flags.legend ? String(args.flags.legend) : null;
409
+ if (legendFile && !args.flags.redact && cmd === 'check') throw new Error('--legend only makes sense with --redact (without --redact the paths are in the output anyway)');
410
+ const legend = legendFile ? Legend.load(legendFile) : null;
411
+ const saveLegend = () => { if (legend && legend.dirty) err(`legend: ${legend.save(legendFile)} (${Object.keys(legend.files).length} files) — keep it; do not send it with the report`); };
412
+ if (cmd === 'reveal') {
413
+ if (!args._[1]) throw new Error('reveal needs a file: glassbox reveal report.md --legend audit.legend.json');
414
+ if (!legendFile || !fs.existsSync(legendFile)) throw new Error('reveal needs --legend FILE (the legend written by check --redact --legend)');
415
+ const r = legend.reveal(fs.readFileSync(args._[1], 'utf8'));
416
+ out(r.text.replace(/\n$/, ''));
417
+ if (r.unknown) err(`reveal: ${r.unknown} key${r.unknown === 1 ? '' : 's'} not in this legend (left as they are)`);
418
+ return 0;
419
+ }
360
420
  if (cmd === 'check' && args.flags.all) {
361
421
  // Every session (optionally only those written in the last --since), one line each; exit 1 if any fails.
362
422
  const since = args.flags.since ? parseSince(args.flags.since) : null;
363
423
  const list = findSessions({ home, project: args.flags.project }).filter((s) => since == null || s.mtime >= Date.now() - since);
364
424
  const fmtOut = outputFormat(args.flags);
365
- const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact }) }));
425
+ const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend }) }));
426
+ saveLegend();
366
427
  const failed = reps.filter((r) => r.rep.failed).length;
367
- if (fmtOut === 'json') out(JSON.stringify({ glassbox: core.VERSION, schema: 1, failOn: args.flags['fail-on'] || 'error', failed: failed > 0, sessions: reps.map((r) => r.rep.json) }, null, 2));
428
+ if (fmtOut === 'json') out(JSON.stringify({ glassbox: core.VERSION, schema: 2, redacted: !!args.flags.redact, legend: !!legend, failOn: args.flags['fail-on'] || 'error', failed: failed > 0, sessions: reps.map((r) => r.rep.json) }, null, 2));
368
429
  else if (fmtOut === 'md') out(reps.map((r) => r.rep.markdown).join('\n---\n\n'));
369
430
  else {
370
431
  if (!reps.length) out(since != null ? `No sessions written in the last ${args.flags.since}.` : noSessionsMessage(home));
@@ -375,7 +436,8 @@ export async function main(argv, io = {}) {
375
436
  }
376
437
  if (cmd === 'check') {
377
438
  const s = resolveTarget(args._[1], { home });
378
- const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact });
439
+ const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend });
440
+ saveLegend();
379
441
  const fmtOut = outputFormat(args.flags);
380
442
  if (fmtOut === 'json') out(JSON.stringify(res.json, null, 2)); else if (fmtOut === 'md') out(res.markdown); else out(res.text);
381
443
  return res.failed ? 1 : 0;
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.5.0';
12
+ const VERSION = '0.6.0';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -985,6 +985,41 @@
985
985
  const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read']);
986
986
  function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
987
987
 
988
+ // ---------------------------------------------------------------------------
989
+ // Per-file use: how often each file was read or written, by how many agents, and how often
990
+ // those calls failed. Paths are normalised (backslashes, case) but otherwise kept; the CLI's
991
+ // legend replaces them with keys when sharing.
992
+ // ---------------------------------------------------------------------------
993
+ const FILE_READ = new Set(['Read']);
994
+ const FILE_WRITE = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
995
+ function filePathOf(c) { const i = c && c.input; if (!i) return null; const p = i.file_path || i.notebook_path; return typeof p === 'string' && p ? p : null; }
996
+ function normalisePath(p) { return String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); }
997
+ function fileStats(trace, opts) {
998
+ const minCalls = (opts && opts.minCalls) || 3, minAgents = (opts && opts.minAgents) || 2;
999
+ const rows = new Map();
1000
+ for (const c of trace.toolCalls) {
1001
+ if (c.unmatched) continue;
1002
+ const isRead = FILE_READ.has(c.name), isWrite = FILE_WRITE.has(c.name);
1003
+ if (!isRead && !isWrite) continue;
1004
+ const p = filePathOf(c); if (!p) continue;
1005
+ const k = normalisePath(p);
1006
+ if (!rows.has(k)) rows.set(k, { path: p, reads: 0, writes: 0, errors: 0, chars: 0, agentSet: new Set(), tools: {} });
1007
+ const r = rows.get(k);
1008
+ if (isRead) { r.reads++; r.chars += c.resultChars || 0; } else r.writes++;
1009
+ if (c.isError) r.errors++;
1010
+ r.agentSet.add(c.agent);
1011
+ r.tools[c.name] = (r.tools[c.name] || 0) + 1;
1012
+ }
1013
+ const out = [];
1014
+ for (const r of rows.values()) {
1015
+ const calls = r.reads + r.writes;
1016
+ if (calls < minCalls && !r.errors && r.agentSet.size < minAgents) continue;
1017
+ out.push({ path: r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agentSet.size, chars: r.chars, tools: r.tools });
1018
+ }
1019
+ out.sort((a, b) => (b.reads + b.writes) - (a.reads + a.writes) || b.errors - a.errors || a.path.localeCompare(b.path));
1020
+ return out;
1021
+ }
1022
+
988
1023
  function reportMarkdown(trace, findings, cost, opts) {
989
1024
  opts = opts || {};
990
1025
  const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
@@ -1140,5 +1175,5 @@
1140
1175
  }
1141
1176
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1142
1177
 
1143
- 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 };
1178
+ 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, fileStats, filePathOf, normalisePath };
1144
1179
  });