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 +11 -2
- package/dist/glassbox.html +37 -2
- package/package.json +2 -2
- package/src/cli.mjs +67 -5
- package/src/trace-core.js +37 -2
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
|
|
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
|
-
|
|
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
|
package/dist/glassbox.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
});
|