glassbox-trace 0.10.0 → 0.11.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/src/memory.mjs ADDED
@@ -0,0 +1,428 @@
1
+ // glassbox memory — what the next session in a project should know, and what the last ones did.
2
+ //
3
+ // `--context` used to carry one session's findings to the next and forget everything else: the note was
4
+ // overwritten by every session with findings and deleted after a clean one, so Monday's lesson was gone by
5
+ // Wednesday. The ledger, <project>/.glassbox/memory.json, keeps a digest of each of the project's last 50 sessions
6
+ // — what was asked, what was written, what ran and how it ended, what was committed, which findings fired — and
7
+ // folds the findings into lessons keyed by rule and subject ("retry-loop · Bash npm test"). The SessionStart hook
8
+ // hands the next session the lessons still recurring and a line per recent session; `glassbox recall` searches
9
+ // the digests and the transcripts still on disk; `glassbox memory` prints exactly what the next session will be
10
+ // told, and how often each lesson came back in sessions that were told it. No AI: every line is copied or counted
11
+ // from a transcript, and anything a fence detector recognises as a secret is masked before it is written.
12
+ // Design: DESIGN.md §20, docs/MEMORY.md.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+ import { maskSecrets } from './fence.mjs';
17
+ import { segments, actionRe, isShell, projectSessions } from './adhere.mjs';
18
+ import { TEST_CMD, runFailed } from './claims.mjs';
19
+ import { loadSessionFiles } from './cli.mjs';
20
+ const require = createRequire(import.meta.url);
21
+ const core = require('./trace-core.js');
22
+
23
+ export const MEMORY_FILE = path.join('.glassbox', 'memory.json');
24
+ export const NOTES_FILE = path.join('.glassbox', 'last-session.md');
25
+ // window: sessions kept · retireAfter: sessions in a row without a lesson before it stops being handed on ·
26
+ // lessons / recent: lines in the note · maxChars: the whole note · pendingDays: how long a "told at the start" record
27
+ // waits for its session's first Stop.
28
+ export const MEMORY_DEFAULTS = { window: 50, retireAfter: 10, lessons: 5, recent: 5, maxChars: 8000, pendingDays: 7 };
29
+ // Not something the agent can do differently: API latency and errors, Claude Code's own missing usage records,
30
+ // a call still running when the hook read the transcript, a cache that expired while the human was away.
31
+ export const NOT_LESSONS = new Set(['slow-model', 'long-generation', 'api-error', 'missing-final-usage', 'orphan-tool', 'idle-cache-expiry']);
32
+ const SEV = { error: 0, warn: 1, info: 2 };
33
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
34
+
35
+ const clip = (s, n) => { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
36
+ // Mask, then cut: a token cut at the limit no longer matches its detector, so cutting first would keep half of it.
37
+ const mclip = (s, n) => clip(maskSecrets(s), n);
38
+ // What the agent wrote for a terminal, as one plain line: no code blocks, headings, bold, list markers or table pipes.
39
+ const plain = (s) => String(s || '').replace(/```[\s\S]*?```/g, ' ').replace(/^#{1,6}\s+/gm, '').replace(/\*\*|__/g, '').replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, '').replace(/\|/g, ' ');
40
+ const iso = (t) => t != null && Number.isFinite(t) ? new Date(t).toISOString() : null;
41
+ const maskDeep = (v) => typeof v === 'string' ? maskSecrets(v) : Array.isArray(v) ? v.map(maskDeep) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, maskDeep(x)])) : v;
42
+ // A path as the project sees it: relative when it is inside the project, forward slashes either way.
43
+ export function relPath(p, cwd) { const f = String(p || '').replace(/\\/g, '/'); const c = String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, ''); return c && f.toLowerCase().startsWith(c.toLowerCase() + '/') ? f.slice(c.length + 1) : f; }
44
+
45
+ // A command segment without the plumbing around it: a `timeout N` in front, redirections of its output behind.
46
+ export const cleanCmd = (seg) => String(seg).replace(/^timeout\s+(?:-\S+\s+)*\d+[smhd]?\s+/, '').replace(/\s+\d*>&\d+|\s+(?:\d|&)?>>?\s*\/dev\/null/g, '').trim();
47
+
48
+ // What a shell command does, in a word or two: the first segment that isn't navigation, its program and subcommand
49
+ // ("npm test", "npm run e2e", "git push", "python -m pytest").
50
+ export function commandHead(cmd) {
51
+ const seg = cleanCmd(segments(cmd).find((s) => !/^(?:cd|pushd|popd|export|set|source|\.)(?:\s|$)/.test(s)) || '');
52
+ const toks = seg.split(/\s+/).filter(Boolean); if (!toks.length) return '';
53
+ const prog = toks[0].replace(/^.*[\\/]/, '');
54
+ if (/^python3?$/.test(prog) && toks[1] === '-m' && toks[2]) return `${prog} -m ${toks[2]}`;
55
+ let i = 1; if (prog === 'git') while (i < toks.length && /^-/.test(toks[i])) i += /^-[Cc]$/.test(toks[i]) ? 2 : 1;
56
+ let sub = toks[i] && /^[A-Za-z][\w:.-]*$/.test(toks[i]) ? toks[i] : toks[1] && /^--[a-z][\w-]*$/.test(toks[1]) ? toks[1] : '';
57
+ if (/^(?:run|exec|x|dlx)$/.test(sub) && toks[i + 1] && /^[\w:.-]+$/.test(toks[i + 1])) sub += ' ' + toks[i + 1];
58
+ return sub ? `${prog} ${sub}` : prog;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------------------------
62
+ // Lessons: a finding, counted across sessions by what it is about. Titles carry counts and sizes, so the key is
63
+ // the rule plus the tool (for a repeated shell call, the command's head), the file, or the claim's kind.
64
+ // ---------------------------------------------------------------------------------------------
65
+ const CALL_RULES = new Set(['retry-loop', 'failed-tool', 'oversized-result', 'slow-tool', 'duplicate-subagent-read']);
66
+ export function lessonOf(f, trace, cwd) {
67
+ if (NOT_LESSONS.has(f.id)) return null;
68
+ const ev = f.evidence || {};
69
+ const c = (ev.toolCallIds || []).map((id) => trace.toolCalls.find((x) => x.id === id)).find(Boolean);
70
+ let subject = '';
71
+ if (f.id === 'retry-loop' && c) { const head = isShell(c) && c.input && typeof c.input.command === 'string' ? commandHead(c.input.command) : ''; subject = c.name + (head ? ' ' + head : ''); }
72
+ else if (['failed-tool', 'oversized-result', 'slow-tool'].includes(f.id) && c) subject = c.name;
73
+ else if (f.id === 'duplicate-subagent-read' && c) subject = relPath(core.filePathOf(c), cwd);
74
+ else if (f.claim) subject = f.claim.kind;
75
+ subject = mclip(subject, 80); // the key is written and shown: `echo <token> | gh auth login` must not put the token in it
76
+ // The example is the call itself where the rule is about one kind of call; elsewhere the finding's own detail.
77
+ const example = c && CALL_RULES.has(f.id) ? mclip(core.inputSummary(c), 120) + (f.id === 'failed-tool' && f.detail ? ' → ' + mclip(f.detail, 120) : '') : mclip(f.detail, 160);
78
+ return { key: f.id + (subject ? ' · ' + subject : ''), rule: f.id, subject, severity: f.severity, title: mclip(f.title, 120), example, call: !!(c && CALL_RULES.has(f.id)) };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------------------------
82
+ // Digests: what one session did, copied from its transcript. Prompts (the first two and the last three), the
83
+ // agent's last words in its last three turns, the files written, the checks and ships that ran with how they
84
+ // ended, the commits with their shas.
85
+ // ---------------------------------------------------------------------------------------------
86
+ const pickEnds = (list, n) => list.length <= n ? list : list.slice(0, 2).concat(list.slice(-(n - 2)));
87
+ // A ship is the real subcommand, never a dry run: `vercel env pull` and `netlify status` deploy nothing.
88
+ const DEPLOY = /^(?:vercel(?:\s+(?:deploy|--prod)\b|\s*$)|(?:netlify|fly|flyctl|firebase|serverless|sls|eb|wrangler|railway)\s+(?:deploy|publish|up)\b|gcloud\s+(?:app|run|functions)\s+deploy\b|(?:npm|pnpm|yarn|bun)\s+run\s+deploy\b|[\w.-]+\s+deploy\b)/i;
89
+ const live = (seg) => !/(?:^|\s)(?:--dry-run|-n)(?=\s|$)/.test(seg);
90
+ const SHIP = { push: (seg) => actionRe.push.test(seg) && live(seg), publish: (seg) => /^(?:npm|pnpm|yarn)\s+publish\b|^gh\s+release\s+create\b/.test(seg) && live(seg), deploy: (seg) => DEPLOY.test(seg) && live(seg) };
91
+ // Failure in the tool's own words, at the start of a line: a commit subject that says "error:" is not a failed push.
92
+ const SHIP_FAILED = { push: /^(?:error|fatal):|\[(?:remote )?rejected\]|failed to push/m, publish: /^npm (?:ERR!|error)\b|^error\b/im, deploy: /^error\b/im };
93
+ // A check is a command that starts with a runner (after npx, pnpm exec, uv run …): `npm install --save-dev vitest`
94
+ // and `cat jest.config.js` name one without running it.
95
+ const RUNNER_PREFIX = /^(?:npx|bunx|pnpx|(?:pnpm|yarn|bun)\s+(?:exec|dlx)|uv\s+run|poetry\s+run)\s+(?:-\S+\s+)*/;
96
+ const isCheck = (seg) => { const m = seg.replace(RUNNER_PREFIX, '').match(TEST_CMD); return !!m && m.index === 0; };
97
+ export function digestOf(trace, opts = {}) {
98
+ const cwd = opts.cwd || trace.meta.cwd || '';
99
+ const human = trace.turns.filter((t) => t.agent === 'main' && t.promptKind === 'human' && String(t.promptText || '').trim());
100
+ const reqs = new Map(trace.requests.map((r) => [r.id, r]));
101
+ const lastWords = (t) => { for (let i = t.requestIds.length - 1; i >= 0; i--) { const r = reqs.get(t.requestIds[i]); const txt = r && (r.blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim(); if (txt) return clip(plain(maskSecrets(txt)), 280); } return ''; };
102
+ const files = []; const seen = new Set();
103
+ const calls = trace.toolCalls.filter((c) => !c.unmatched).slice().sort((a, b) => (a.start || 0) - (b.start || 0));
104
+ for (const c of calls) { if (c.isError || !WRITE_TOOLS.has(c.name)) continue; const p = core.filePathOf(c); if (!p) continue; const r = relPath(maskSecrets(p), cwd); if (!seen.has(r.toLowerCase())) { seen.add(r.toLowerCase()); files.push(r); } }
105
+ const runs = new Map(), commits = [];
106
+ for (const c of calls) {
107
+ if (!isShell(c) || !c.input || typeof c.input.command !== 'string') continue;
108
+ const segs = segments(c.input.command).map(cleanCmd), out = c.resultText || '';
109
+ if (segs.some((s) => actionRe.commit.test(s)) && !c.isError) for (const m of out.matchAll(/^\[[^\]\n]*?\b([0-9a-f]{7,40})\]\s*([^\n]*)/gm)) commits.push({ sha: m[1].slice(0, 12), message: mclip(m[2], 100) });
110
+ if (c.input.run_in_background) continue; // its result is "running in the background", not how it ended
111
+ // A call that runs several checks ("npm test && npm run build") is one run: its result can't be split between them.
112
+ const checks = segs.filter(isCheck);
113
+ if (checks.length) { const cmd = mclip(checks.join(' && '), 80); const k = 'check|' + cmd.toLowerCase(); const prev = runs.get(k); runs.delete(k); runs.set(k, { kind: 'check', cmd, ok: !runFailed(c), runs: (prev ? prev.runs : 0) + 1 }); }
114
+ for (const [kind, test] of Object.entries(SHIP)) {
115
+ const seg = segs.find(test); if (!seg) continue;
116
+ const cmd = mclip(seg, 80), k = kind + '|' + cmd.toLowerCase(); const prev = runs.get(k); runs.delete(k);
117
+ runs.set(k, { kind, cmd, ok: !c.isError && !SHIP_FAILED[kind].test(out), runs: (prev ? prev.runs : 0) + 1 });
118
+ }
119
+ }
120
+ const prompts = human.map((t) => mclip(t.promptText, 200));
121
+ return maskDeep({
122
+ id: opts.id || trace.meta.sessionId || null, start: iso(trace.meta.start), end: iso(trace.meta.end),
123
+ title: trace.meta.title ? mclip(trace.meta.title, 90) : clip(prompts[0] || '', 90), prompts: pickEnds(prompts, 5), promptsTotal: prompts.length,
124
+ outcomes: Array.from(new Set(human.slice(-3).map(lastWords).filter(Boolean))),
125
+ files: files.slice(0, 30), filesTotal: files.length,
126
+ commands: Array.from(runs.values()).slice(-10), commits: commits.slice(-10),
127
+ turns: trace.totals.turns, toolCalls: trace.totals.toolCalls, toolErrors: trace.totals.toolErrors,
128
+ cost: opts.cost != null && Number.isFinite(opts.cost) ? Math.round(opts.cost * 1e4) / 1e4 : null,
129
+ });
130
+ }
131
+
132
+ // The trace with every tool input masked, for reports that are kept (last-session.md): they cut evidence to 100
133
+ // characters, and a token cut in half no longer matches its detector.
134
+ export function maskedTrace(trace) {
135
+ return Object.assign({}, trace, { meta: Object.assign({}, trace.meta, { title: trace.meta.title == null ? trace.meta.title : maskSecrets(trace.meta.title) }), toolCalls: trace.toolCalls.map((c) => c.input ? Object.assign({}, c, { input: maskDeep(c.input) }) : c) });
136
+ }
137
+
138
+ // What a session did, in a few short phrases; one line per session for the note and for recall without a query.
139
+ export function digestBits(d) {
140
+ const bits = [];
141
+ if (d.files && d.files.length) bits.push(`wrote ${d.files.slice(0, 3).join(', ')}${d.filesTotal > 3 ? ` +${d.filesTotal - 3} more` : ''}`);
142
+ for (const c of (d.commands || []).filter((x) => x.kind === 'check').slice(-2)) bits.push(`ran ${c.cmd} (${c.ok ? 'passed' : 'failed'}${c.runs > 1 ? `, last of ${c.runs}` : ''})`);
143
+ for (const c of (d.commits || []).slice(-2)) bits.push(`committed ${c.sha.slice(0, 7)} "${clip(c.message, 60)}"`);
144
+ for (const c of (d.commands || []).filter((x) => x.kind !== 'check').slice(-2)) bits.push(`${({ push: 'pushed', publish: 'published', deploy: 'deployed' })[c.kind]}${c.ok ? '' : ' (failed)'}`);
145
+ if (d.hit && d.hit.length) bits.push(`findings: ${d.hit.slice(0, 3).join(', ')}${d.hit.length > 3 ? ` +${d.hit.length - 3}` : ''}`);
146
+ return bits;
147
+ }
148
+ export function digestLine(d) {
149
+ const bits = digestBits(d);
150
+ return `${String(d.start || '').slice(0, 16).replace('T', ' ') || '—'} · ${String(d.id || '').slice(0, 8)} · "${clip(d.title, 70)}"${bits.length ? ' — ' + bits.join(' · ') : ''}`;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------------------------
154
+ // The ledger
155
+ // ---------------------------------------------------------------------------------------------
156
+ export function memoryPath(cwd) { return cwd && fs.existsSync(cwd) && fs.statSync(cwd).isDirectory() ? path.join(cwd, MEMORY_FILE) : null; }
157
+ export function emptyLedger() { return { glassbox: core.VERSION, kind: 'memory', schema: 1, note: 'Glassbox memory for this project, written by the Glassbox hook (--context) from its transcripts. Local and git-ignored; `glassbox memory` shows what the next session will be told.', updated: null, sessions: [], lessons: {}, pending: {}, forgotten: {} }; }
158
+ export function loadLedger(cwd) {
159
+ const file = memoryPath(cwd); if (!file || !fs.existsSync(file)) return emptyLedger();
160
+ // A ledger that doesn't parse starts over: it is rebuilt from transcripts, and the hook must never fail on it.
161
+ let j = null; try { j = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { }
162
+ if (!j || j.kind !== 'memory' || !Array.isArray(j.sessions) || !j.lessons || typeof j.lessons !== 'object') return emptyLedger();
163
+ return Object.assign(emptyLedger(), j, { pending: j.pending && typeof j.pending === 'object' ? j.pending : {}, forgotten: j.forgotten && typeof j.forgotten === 'object' ? j.forgotten : {} });
164
+ }
165
+ export function ensureMemoryDir(cwd) {
166
+ const dir = path.join(cwd, '.glassbox'); fs.mkdirSync(dir, { recursive: true });
167
+ const ignore = path.join(dir, '.gitignore'); if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '# written by glassbox hook --context; local notes, never committed\n*\n');
168
+ return dir;
169
+ }
170
+ // Temp file and rename, so a session reading the ledger never sees half of one; owner-only, it quotes prompts.
171
+ export function saveLedger(cwd, led) {
172
+ const file = memoryPath(cwd); if (!file) return null;
173
+ ensureMemoryDir(cwd);
174
+ led.glassbox = core.VERSION; led.updated = new Date().toISOString();
175
+ const tmp = `${file}.${process.pid}.tmp`;
176
+ fs.writeFileSync(tmp, JSON.stringify(led, null, 1) + '\n', { mode: 0o600 }); fs.renameSync(tmp, file);
177
+ return file;
178
+ }
179
+
180
+ // Fold one session into the ledger. The Stop hook runs after every turn, so this replaces the session's digest
181
+ // and lesson hits each time rather than adding to them: re-folding a session changes nothing.
182
+ export function recordSession(cwd, res, opts = {}) {
183
+ const file = memoryPath(cwd); if (!file) return null;
184
+ const o = Object.assign({}, MEMORY_DEFAULTS, opts), failOn = o.failOn || 'warn';
185
+ const led = loadLedger(cwd);
186
+ const cost = res.cost ? (res.cost.reported != null ? res.cost.reported : res.cost.total) : null;
187
+ const d = digestOf(res.trace, { cwd, cost, id: o.sessionId });
188
+ const lessons = []; const keys = new Set();
189
+ // A lesson forgotten while this session was running stays forgotten for it; sessions started later count again.
190
+ const forgotten = (k) => led.forgotten[k] && !(d.start && d.start > led.forgotten[k]);
191
+ for (const f of res.findings) { if (SEV[f.severity] > SEV[failOn]) continue; const l = lessonOf(f, res.trace, cwd); if (l && !keys.has(l.key) && !forgotten(l.key)) { keys.add(l.key); lessons.push(l); } }
192
+ d.hit = lessons.map((l) => l.key);
193
+ const i = led.sessions.findIndex((s) => s.id === d.id);
194
+ d.told = i >= 0 ? led.sessions[i].told || [] : (led.pending[d.id] && led.pending[d.id].told) || [];
195
+ delete led.pending[d.id];
196
+ if (i >= 0) led.sessions[i] = d; else led.sessions.push(d);
197
+ led.sessions.sort((a, b) => String(a.start || '').localeCompare(String(b.start || '')));
198
+ if (led.sessions.length > o.window) led.sessions = led.sessions.slice(-o.window);
199
+ const when = d.end || d.start || new Date().toISOString();
200
+ for (const l of lessons) { const p = led.lessons[l.key]; led.lessons[l.key] = { rule: l.rule, subject: l.subject, severity: l.severity, title: l.title, example: l.example, call: l.call, firstSeen: p ? p.firstSeen : when, lastSeen: when }; }
201
+ // A lesson no session in the window carries any more has aged out; so has a start record whose session never stopped.
202
+ const live = new Set(led.sessions.flatMap((s) => s.hit || []));
203
+ for (const k of Object.keys(led.lessons)) if (!live.has(k)) delete led.lessons[k];
204
+ const cutoff = Date.now() - o.pendingDays * 86400e3;
205
+ for (const [k, p] of Object.entries(led.pending)) if (!(Date.parse(p && p.at) >= cutoff)) delete led.pending[k];
206
+ saveLedger(cwd, led);
207
+ return { file, digest: d, lessons };
208
+ }
209
+
210
+ // Per lesson, over the sessions in the window: how many it fired in, how many started with it in their note, how
211
+ // many of those it fired in anyway, and how many sessions have gone by since it last fired.
212
+ export function lessonStats(led, opts = {}) {
213
+ const o = Object.assign({}, MEMORY_DEFAULTS, opts), S = led.sessions;
214
+ const out = Object.entries(led.lessons).map(([key, l]) => {
215
+ let sessions = 0, told = 0, recurred = 0, last = -1;
216
+ S.forEach((s, i) => { const h = (s.hit || []).includes(key), t = (s.told || []).includes(key); if (h) { sessions++; last = i; } if (t) { told++; if (h) recurred++; } });
217
+ const quiet = last < 0 ? S.length : S.length - 1 - last;
218
+ return Object.assign({ key }, l, { sessions, told, recurred, quiet, active: last >= 0 && quiet < o.retireAfter });
219
+ });
220
+ return out.sort((a, b) => (b.active - a.active) || SEV[a.severity] - SEV[b.severity] || b.sessions - a.sessions || String(b.lastSeen).localeCompare(String(a.lastSeen)));
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------------------------
224
+ // The note: what the SessionStart hook hands the next session. Lessons first, then a line per recent session,
225
+ // then the previous session's own findings and the agent's answer (last-session.md), cut to fit maxChars.
226
+ // ---------------------------------------------------------------------------------------------
227
+ const NOTE_HEAD = 'Notes from Glassbox on this project: copied and counted from the transcripts of its recent sessions by the Glassbox hook (no AI involved), kept in .glassbox/. Treat them as lessons, not tasks: avoid repeating what they describe, and don\'t redo past work.';
228
+ export function memoryNote(cwd, opts = {}) {
229
+ if (!memoryPath(cwd)) return null;
230
+ const o = Object.assign({}, MEMORY_DEFAULTS, opts);
231
+ const led = opts.ledger || loadLedger(cwd);
232
+ const S = led.sessions, n = S.length;
233
+ const active = lessonStats(led, o).filter((l) => l.active);
234
+ const shown = active.slice(0, o.lessons), advised = new Set();
235
+ const parts = [NOTE_HEAD];
236
+ if (shown.length) {
237
+ const L = [`## Recurring in this project (${shown.length < active.length ? `${shown.length} of ${active.length}, ` : ''}worst first)`, ''];
238
+ for (const l of shown) {
239
+ const adv = !advised.has(l.rule) && core.ADVICE[l.rule]; advised.add(l.rule);
240
+ const howOften = n === 1 ? 'in the only session so far' : `in ${l.sessions} of the last ${n} sessions`;
241
+ const eg = !l.example ? '' : l.call ? `, e.g. \`${clip(l.example, 100).replace(/`/g, "'")}\`` : `. ${clip(l.example, 160)}`;
242
+ L.push(`- ${l.key} — ${howOften}, last ${String(l.lastSeen || '').slice(0, 10)}: ${l.title}${eg}${/[.…]$/.test(eg || l.title) ? '' : '.'}${adv ? ` Next time: ${adv}` : ''}`);
243
+ }
244
+ parts.push(L.join('\n'));
245
+ }
246
+ const recent = S.slice().reverse().filter((d) => d.id !== opts.exclude && (d.toolCalls || (d.prompts && d.prompts.length))).slice(0, o.recent);
247
+ if (recent.length) parts.push(['## Recent sessions (newest first)', '', ...recent.map((d) => '- ' + digestLine(d))].join('\n'));
248
+ // The previous session's own notes. With lessons above, its findings are already there: keep its header line, the
249
+ // ones that didn't make the list, and what the agent said it would do differently. Without lessons (notes left
250
+ // by an older Glassbox, or a ledger that was cleared), the notes as they are.
251
+ const notes = path.join(cwd, NOTES_FILE);
252
+ const raw = fs.existsSync(notes) ? fs.readFileSync(notes, 'utf8') : '';
253
+ let last = '';
254
+ if (raw) {
255
+ const head = '## The previous session in this project (.glassbox/last-session.md)\n\n';
256
+ const line = (raw.match(/^# [^\n]*\n+([^\n#][^\n]*)\n/) || [])[1] || '';
257
+ const reply = ((raw.match(/\n## What the agent said it would do differently\n+([\s\S]*?)\s*(?=\n## |$)/) || [])[1] || '').trim();
258
+ if (shown.length && line) {
259
+ const id8 = (line.match(/session ([0-9a-f-]{4,})/i) || [])[1] || '';
260
+ const rec = id8 ? S.slice().reverse().find((d) => String(d.id || '').startsWith(id8)) : null;
261
+ const rest = rec ? (rec.hit || []).filter((k) => !shown.some((l) => l.key === k)) : [];
262
+ last = `${line}. Its findings are ${rest.length ? `the lessons above plus ${rest.join(', ')}` : 'among the lessons above'}${id8 ? `; \`glassbox check ${id8} --format md\` has the evidence` : ''}.` + (reply ? `\n\nWhat the agent said it would do differently: ${clip(reply, 1500)}` : '');
263
+ } else last = raw.replace(/^# [^\n]*\n+/, '').trim();
264
+ const room = o.maxChars - parts.join('\n\n').length - head.length - 2;
265
+ if (room < 600) last = '';
266
+ else if (last.length > room) last = last.slice(0, room - 90).replace(/\n[^\n]*$/, '') + '\n\n… (cut to fit; `glassbox check <session> --format md` has the full report)';
267
+ if (last) parts.push(head + last);
268
+ }
269
+ if (parts.length === 1) return null;
270
+ let text = parts.join('\n\n') + '\n';
271
+ if (text.length > o.maxChars) text = text.slice(0, o.maxChars - 2).replace(/\n[^\n]*$/, '') + '\n…\n';
272
+ return { text, told: shown.map((l) => l.key), lessons: shown.length, recent: recent.length, previous: !!last };
273
+ }
274
+
275
+ // A session that starts with lessons in its note is "told" them; counted only at the start (startup, /clear), never
276
+ // when compaction hands the note over again mid-session. Kept as pending until the session's first Stop.
277
+ export function noteTold(cwd, sessionId, keys) {
278
+ if (!sessionId || !keys || !keys.length || !memoryPath(cwd)) return false;
279
+ const led = loadLedger(cwd);
280
+ if (led.sessions.some((s) => s.id === sessionId)) return false;
281
+ led.pending[sessionId] = { at: new Date().toISOString(), told: keys };
282
+ saveLedger(cwd, led);
283
+ return true;
284
+ }
285
+
286
+ // Forget lessons by key, key prefix or rule id: the lesson and every count of it go, so it starts over if it fires again.
287
+ export function forget(cwd, what) {
288
+ const led = loadLedger(cwd), q = String(what || '').trim().toLowerCase();
289
+ if (!q) throw new Error('memory forget needs a lesson key or a rule id: glassbox memory forget retry-loop');
290
+ const keys = Object.keys(led.lessons).filter((k) => k.toLowerCase() === q || led.lessons[k].rule === q || k.toLowerCase().startsWith(q + ' · '));
291
+ if (!keys.length) return [];
292
+ const drop = (list) => (list || []).filter((k) => !keys.includes(k));
293
+ const at = new Date().toISOString();
294
+ for (const k of keys) { delete led.lessons[k]; led.forgotten[k] = at; }
295
+ for (const s of led.sessions) { s.hit = drop(s.hit); s.told = drop(s.told); }
296
+ for (const p of Object.values(led.pending)) p.told = drop(p.told);
297
+ saveLedger(cwd, led);
298
+ return keys;
299
+ }
300
+
301
+ export function clearMemory(cwd) {
302
+ const removed = [];
303
+ for (const f of [MEMORY_FILE, NOTES_FILE]) { const p = path.join(cwd, f); if (fs.existsSync(p)) { fs.unlinkSync(p); removed.push(p); } }
304
+ return removed;
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------------------------
308
+ // glassbox memory: the note the next session will get, and the lessons behind it.
309
+ // ---------------------------------------------------------------------------------------------
310
+ export function memoryReport(opts = {}) {
311
+ const project = path.resolve(opts.project || process.cwd());
312
+ const led = loadLedger(project), o = Object.assign({}, MEMORY_DEFAULTS, opts);
313
+ const lessons = lessonStats(led, o);
314
+ const note = memoryNote(project, Object.assign({}, o, { ledger: led }));
315
+ const told = lessons.reduce((s, l) => s + l.told, 0), recurred = lessons.reduce((s, l) => s + l.recurred, 0);
316
+ return { glassbox: core.VERSION, kind: 'memory', schema: 1, project, file: fs.existsSync(path.join(project, MEMORY_FILE)) ? path.join(project, MEMORY_FILE) : null, updated: led.updated,
317
+ sessions: led.sessions.length, window: o.window, retireAfter: o.retireAfter,
318
+ summary: { lessons: lessons.length, active: lessons.filter((l) => l.active).length, retired: lessons.filter((l) => !l.active).length, told, recurred },
319
+ lessons, recent: led.sessions.slice().reverse().slice(0, o.recent).map((d) => ({ id: d.id, start: d.start, line: digestLine(d) })), note: note ? note.text : null };
320
+ }
321
+ export function memoryText(rep) {
322
+ const s = rep.summary;
323
+ const L = [`Glassbox memory · ${rep.project}`];
324
+ if (!rep.file) L.push(' no memory yet — turn it on with `glassbox hook install --context` (or the plugin\'s context option); `glassbox recall` works without it');
325
+ else L.push(` ${rep.sessions} session${rep.sessions === 1 ? '' : 's'} remembered (window ${rep.window}) · ${s.lessons} lesson${s.lessons === 1 ? '' : 's'}: ${s.active} active, ${s.retired} retired after ${rep.retireAfter} quiet sessions${s.told ? ` · told ${s.told} times, came back ${s.recurred} of them` : ''}`);
326
+ if (rep.lessons.length) {
327
+ L.push('');
328
+ for (const l of rep.lessons) L.push(` ${(l.active ? 'ACTIVE' : 'RETIRED').padEnd(8)} ${l.key} · ${l.sessions} session${l.sessions === 1 ? '' : 's'}, last ${String(l.lastSeen || '').slice(0, 10)}${l.told ? ` · told ${l.told}, came back ${l.recurred}` : ''}${l.quiet ? ` · quiet ${l.quiet}` : ''}`);
329
+ }
330
+ L.push('', rep.note ? ' The next session in this project starts with this note:' : ' The next session in this project starts with no note.');
331
+ if (rep.note) { L.push(''); for (const line of rep.note.replace(/\n$/, '').split('\n')) L.push(' │ ' + line); }
332
+ L.push('', ' glassbox memory forget <key|rule> drops a lesson · glassbox memory clear deletes the memory · glassbox recall <words> searches it');
333
+ return L.join('\n') + '\n';
334
+ }
335
+ export function memoryMarkdown(rep) {
336
+ const s = rep.summary;
337
+ const L = ['# Glassbox memory', '', `Project \`${rep.project}\` · ${rep.sessions} sessions remembered · ${s.active} active lessons, ${s.retired} retired${s.told ? ` · told ${s.told} times, came back ${s.recurred}` : ''}`, ''];
338
+ if (rep.lessons.length) { L.push('| lesson | status | sessions | told | came back | quiet | last |', '|---|---|---:|---:|---:|---:|---|'); for (const l of rep.lessons) L.push(`| \`${l.key}\` | ${l.active ? 'active' : 'retired'} | ${l.sessions} | ${l.told} | ${l.recurred} | ${l.quiet} | ${String(l.lastSeen || '').slice(0, 10)} |`); L.push(''); }
339
+ L.push('## The note the next session gets', '', rep.note ? rep.note.replace(/\n$/, '').split('\n').map((x) => '> ' + x).join('\n') : '_none_', '');
340
+ return L.join('\n');
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------------------------
344
+ // glassbox recall: search a project's past sessions — the digests in the ledger and the transcripts still on disk
345
+ // (Claude Code deletes old transcripts; the ledger outlives them). Every word must match; a quoted phrase is one.
346
+ // ---------------------------------------------------------------------------------------------
347
+ export function parseQuery(q) { const out = []; String(q || '').replace(/"([^"]+)"|(\S+)/g, (m, ph, w) => { const t = (ph || w).toLowerCase().trim(); if (t) out.push(t); return ''; }); return out; }
348
+ const FIELDS = [['title', 3, (d) => [d.title]], ['prompt', 3, (d) => d.prompts || []], ['commit', 3, (d) => (d.commits || []).map((c) => `${c.sha.slice(0, 7)} ${c.message}`)], ['outcome', 2, (d) => d.outcomes || []], ['wrote', 2, (d) => d.files || []], ['ran', 1, (d) => (d.commands || []).map((c) => `${c.cmd} (${c.ok ? 'passed' : 'failed'})`)], ['finding', 1, (d) => d.hit || []]];
349
+ // A word missing from a transcript's raw text can't be in its digest; only words JSON would escape need parsing to tell.
350
+ const rawSafe = (w) => /^[\x20-\x7e]+$/.test(w) && !/["\\/]/.test(w) && !/^(?:passed|failed)$|fenced/i.test(w);
351
+ function around(text, term, n = 150) {
352
+ const t = String(text).replace(/\s+/g, ' ').trim(); const i = t.toLowerCase().indexOf(term); if (i < 0 || t.length <= n) return clip(t, n);
353
+ let a = Math.max(0, Math.min(i - 40, t.length - n)), b = Math.min(t.length, a + n);
354
+ if (a) { const sp = t.indexOf(' ', a); if (sp > -1 && sp < i) a = sp + 1; }
355
+ if (b < t.length) { const sp = t.lastIndexOf(' ', b); if (sp > i + term.length) b = sp; }
356
+ return (a ? '…' : '') + t.slice(a, b) + (b < t.length ? '…' : '');
357
+ }
358
+ export function recall(opts = {}) {
359
+ const project = path.resolve(opts.project || process.cwd());
360
+ const terms = parseQuery(opts.query), limit = opts.limit || 10;
361
+ const led = loadLedger(project);
362
+ const byId = new Map(led.sessions.map((d) => [d.id, Object.assign({ source: 'memory' }, d)]));
363
+ const onDisk = projectSessions(project, opts.home);
364
+ let list = opts.since != null ? onDisk.filter((s) => s.mtime >= opts.since) : onDisk;
365
+ const transcripts = list.length;
366
+ const inWindow = (d) => opts.since == null || !(Date.parse(d.end || d.start) < opts.since);
367
+ const universe = new Set([...led.sessions.filter(inWindow).map((d) => d.id), ...list.map((s) => s.id)]);
368
+ // Without words only the newest few can be shown, so only they are parsed (the list is newest first).
369
+ if (!terms.length) list = list.slice(0, limit);
370
+ let parsed = 0;
371
+ for (const s of list) {
372
+ const files = loadSessionFiles(s);
373
+ const words = terms.flatMap((t) => t.split(/\s+/)).filter(rawSafe);
374
+ if (words.length) { const raw = files.map((f) => f.text).join('\n').toLowerCase(); if (!words.every((w) => raw.includes(w))) continue; }
375
+ const d = digestOf(core.parseTrace(files), { cwd: project, id: s.id }); parsed++;
376
+ const prev = byId.get(s.id);
377
+ byId.set(s.id, Object.assign(d, { hit: prev ? prev.hit : [], cost: prev ? prev.cost : null, source: prev ? 'both' : 'transcript' }));
378
+ }
379
+ // A ledger digest whose transcript is still on disk is the same session: say so.
380
+ const disk = new Set(onDisk.map((s) => s.id));
381
+ for (const d of byId.values()) if (d.source === 'memory' && disk.has(d.id)) d.source = 'both';
382
+ const rows = Array.from(byId.values()).filter(inWindow);
383
+ const out = [];
384
+ for (const d of rows) {
385
+ let score = 0; const matches = [];
386
+ if (terms.length) {
387
+ const hay = FIELDS.map(([, , get]) => get(d).join('\n').toLowerCase()).join('\n');
388
+ if (!terms.every((t) => hay.includes(t))) continue;
389
+ const all = [];
390
+ for (const [field, w, get] of FIELDS) for (const v of get(d)) { const hits = terms.filter((t) => String(v).toLowerCase().includes(t)); if (!hits.length) continue; score += w * hits.length; if (!(field === 'title' && (d.prompts || []).includes(v))) all.push({ field, text: around(v, hits[0]) }); }
391
+ // One match per field before a second from any: what was asked, said, written and run, not five files.
392
+ const firsts = all.filter((m, i) => all.findIndex((x) => x.field === m.field) === i);
393
+ matches.push(...firsts.concat(all.filter((m) => !firsts.includes(m))).slice(0, 5));
394
+ }
395
+ out.push({ id: d.id, start: d.start, end: d.end, title: d.title, source: d.source, score, matches, bits: digestBits(d), digest: d });
396
+ }
397
+ out.sort((a, b) => b.score - a.score || String(b.start || '').localeCompare(String(a.start || '')));
398
+ const lessons = terms.length ? lessonStats(led).filter((l) => terms.every((t) => [l.key, l.title, l.example].join('\n').toLowerCase().includes(t))) : [];
399
+ return { glassbox: core.VERSION, kind: 'recall', schema: 1, project, query: terms, generated: new Date().toISOString(), searched: { sessions: universe.size, transcripts, parsed, memory: led.sessions.length }, total: out.length, sessions: out.slice(0, limit), lessons };
400
+ }
401
+ const LABEL = { title: 'title', prompt: 'asked', commit: 'commit', outcome: 'said', wrote: 'wrote', ran: 'ran', finding: 'finding' };
402
+ export function recallText(rep) {
403
+ const q = rep.query.length ? ` · ${rep.query.map((t) => t.includes(' ') ? `"${t}"` : t).join(' ')}` : '';
404
+ const L = [`Glassbox recall · ${rep.project}${q} · ${rep.query.length ? `${rep.total} of ${rep.searched.sessions}` : rep.searched.sessions} session${rep.searched.sessions === 1 ? '' : 's'}`];
405
+ if (!rep.searched.sessions) L.push(' nothing to search: no transcripts for this project under the Claude home and no .glassbox/memory.json (try --project DIR or --home DIR)');
406
+ else if (!rep.sessions.length) L.push(` no session matches${rep.query.length > 1 ? ' all of the words' : ''}`);
407
+ for (const r of rep.sessions) {
408
+ L.push('', ` ${String(r.start || '').slice(0, 16).replace('T', ' ')} ${String(r.id || '').slice(0, 8)} ${clip(r.title, 90)}${r.source === 'memory' ? ' (transcript gone; from memory)' : ''}`);
409
+ if (!rep.query.length) { L.push(' ' + (r.bits.join(' · ') || '(no writes, checks or commits)')); continue; }
410
+ for (const m of r.matches) L.push(` ${LABEL[m.field].padEnd(8)} ${m.text}`);
411
+ }
412
+ if (rep.total > rep.sessions.length) L.push('', ` … ${rep.total - rep.sessions.length} more (--limit N)`);
413
+ if (rep.lessons.length) { L.push('', ' lessons'); for (const l of rep.lessons) L.push(` ${l.key} — ${l.sessions} session${l.sessions === 1 ? '' : 's'}, last ${String(l.lastSeen || '').slice(0, 10)}${l.active ? '' : ' (retired)'}: ${l.title}`); }
414
+ if (rep.sessions.length) L.push('', ' glassbox open <id> shows a session\'s timeline · glassbox check <id> its findings');
415
+ return L.join('\n') + '\n';
416
+ }
417
+ export function recallMarkdown(rep) {
418
+ const q = rep.query.length ? rep.query.map((t) => t.includes(' ') ? `"${t}"` : t).join(' ') : '';
419
+ const L = [`# Glassbox recall${q ? ` — ${q}` : ''}`, '', `Project \`${rep.project}\` · ${rep.query.length ? `${rep.total} of ${rep.searched.sessions}` : rep.searched.sessions} session${rep.searched.sessions === 1 ? '' : 's'} · copied from transcripts and .glassbox/memory.json, ${rep.query.length ? 'best match' : 'newest'} first`, ''];
420
+ if (!rep.sessions.length) L.push(rep.searched.sessions ? '_No session matches._' : '_Nothing to search for this project._', '');
421
+ for (const r of rep.sessions) {
422
+ L.push(`## ${String(r.start || '').slice(0, 16).replace('T', ' ')} · \`${String(r.id || '').slice(0, 8)}\` · ${clip(r.title, 90)}`, '');
423
+ if (!rep.query.length) L.push(r.bits.join(' · ') || '_(no writes, checks or commits)_', '');
424
+ else { for (const m of r.matches) L.push(`- ${LABEL[m.field]}: ${m.text}`); L.push(''); }
425
+ }
426
+ if (rep.lessons.length) { L.push('## Lessons', ''); for (const l of rep.lessons) L.push(`- \`${l.key}\` — ${l.sessions} session${l.sessions === 1 ? '' : 's'}, last ${String(l.lastSeen || '').slice(0, 10)}${l.active ? '' : ' (retired)'}: ${l.title}`); L.push(''); }
427
+ return L.join('\n');
428
+ }
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.10.0';
12
+ const VERSION = '0.11.0';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -417,7 +417,7 @@
417
417
  let turn = currentTurn(a.id);
418
418
  if (!turn) turn = ensureTurn(rec, 'implicit', '');
419
419
  req = {
420
- id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t,
420
+ id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t, version: rec.version || null,
421
421
  usage: emptyUsage(), blocks: [], turnIndex: turn.index, contextTokens: 0, records: 0,
422
422
  isApiError: !!rec.isApiErrorMessage, uuid: rec.uuid || null,
423
423
  };
@@ -432,6 +432,7 @@
432
432
  maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
433
433
  req.records++;
434
434
  if (msg.stop_reason) req.stopReason = msg.stop_reason;
435
+ if (!req.version && rec.version) req.version = rec.version;
435
436
  if (rec.isApiErrorMessage) req.isApiError = true;
436
437
  const content = Array.isArray(msg.content) ? msg.content : (msg.content != null ? [{ type: 'text', text: String(msg.content) }] : []);
437
438
  const blockIndex = typeof rec.apiBlockIndex === 'number' ? rec.apiBlockIndex : req.blocks.length;
@@ -469,6 +470,10 @@
469
470
  req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
470
471
  addUsage(turns[req.turnIndex].usage, req.usage);
471
472
  addUsage(agents.get(req.agent).usage, req.usage);
473
+ // A response whose records all have stop_reason null never got its final record written, so its
474
+ // output_tokens is a partial running count. Only subagent transcripts are judged: in the main
475
+ // transcript that shape is the live, unfinished last response (anthropics/claude-code#93620).
476
+ req.usageFinal = !(req.agent !== 'main' && !req.stopReason && !req.isApiError && req.model !== '<synthetic>' && !isEmptyUsage(req.usage));
472
477
  }
473
478
  for (const turn of turns) if (turn.end == null) turn.end = meta.end;
474
479
  // Response timing. Assistant record timestamps are written when a block finishes streaming,
@@ -528,12 +533,28 @@
528
533
  toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
529
534
  modelTimeMs: agents.get('main').modelTimeMs || 0,
530
535
  cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
531
- attachments,
536
+ attachments, usagePartial: usagePartial(requests),
532
537
  };
533
538
 
534
539
  return { version: VERSION, meta, requests, toolCalls, turns, agents: agentList, events, problems, totals };
535
540
  }
536
541
 
542
+ // Subagent responses with no final usage record: how many, what was seen, and an estimate of the gap from the
543
+ // session's own complete subagent tool_use responses (median; needs at least five to go on).
544
+ function usagePartial(requests) {
545
+ const sub = requests.filter((r) => r.agent !== 'main' && r.model !== '<synthetic>' && !r.isApiError && !isEmptyUsage(r.usage));
546
+ const partial = sub.filter((r) => r.usageFinal === false);
547
+ const out = { responses: partial.length, of: sub.length, outputSeen: partial.reduce((n, r) => n + r.usage.output, 0), versions: Array.from(new Set(partial.map((r) => r.version).filter(Boolean))).sort(), agents: new Set(partial.map((r) => r.agent)).size, estimate: null };
548
+ if (!partial.length) return out;
549
+ const basis = sub.filter((r) => r.usageFinal && r.stopReason === 'tool_use').map((r) => r.usage.output).sort((a, b) => a - b);
550
+ if (basis.length >= 5) {
551
+ const mid = basis.length >> 1;
552
+ const median = basis.length % 2 ? basis[mid] : (basis[mid - 1] + basis[mid]) / 2;
553
+ out.estimate = { basis: basis.length, median, missingOutput: partial.reduce((n, r) => n + Math.max(0, median - r.usage.output), 0) };
554
+ }
555
+ return out;
556
+ }
557
+
537
558
  function pushModel(meta, m) { if (m && !meta.models.includes(m)) meta.models.push(m); }
538
559
 
539
560
  function mkOrphanResult(tr, rec, agentId, text, turnIndex) {
@@ -819,6 +840,21 @@
819
840
  if (e.kind === 'compact') push({ id: 'compaction', severity: 'info', title: 'Context was compacted', detail: e.detail, evidence: { at: e.at } });
820
841
  }
821
842
 
843
+ // missing-final-usage: the transcript under-counts subagent output (Claude Code bug, not the agent's doing)
844
+ const P = trace.totals.usagePartial;
845
+ if (P && P.responses > 0) {
846
+ const c = estimateCost(trace, o.rates);
847
+ const est = P.estimate
848
+ ? ` From the median of ${P.estimate.basis} complete subagent tool-use responses in this session (${fmtInt(P.estimate.median)} output tokens each), about ${fmtInt(P.estimate.missingOutput)} output tokens${c.missing != null ? ` (~${usd(c.missing)})` : ''} are not counted.`
849
+ : ' Too few complete subagent responses in this session (fewer than 5) to estimate the gap.';
850
+ push({
851
+ id: 'missing-final-usage', severity: 'info',
852
+ title: `Cost is a floor: ${P.responses} of ${P.of} subagent responses never recorded their final output tokens`,
853
+ detail: `These responses were written to the subagent transcript with stop_reason null on every record and only a partial running output_tokens count (${fmtInt(P.outputSeen)} tokens across all of them), so their real output is not on disk.${est} Claude Code ${P.versions.join(', ') || '(version not recorded)'}; known transcript issue anthropics/claude-code#93620.`,
854
+ evidence: { requestIds: trace.requests.filter((r) => r.usageFinal === false).slice(0, 20).map((r) => r.id) }, metric: P.responses,
855
+ });
856
+ }
857
+
822
858
  // thinking-heavy
823
859
  const u = trace.totals.usage;
824
860
  if (u.output >= o.thinkingHeavyMinOutput && u.thinking / u.output > o.thinkingHeavyShare) push({
@@ -902,7 +938,17 @@
902
938
  byAgent[r.agent] = (byAgent[r.agent] || 0) + c;
903
939
  }
904
940
  const complete = known === trace.requests.length;
941
+ // Floor: some subagent responses never recorded their final output (see usagePartial). A run-reported cost
942
+ // is the real bill and stays as it is.
943
+ const P = (trace.totals && trace.totals.usagePartial) || { responses: 0, estimate: null };
944
+ const lowerBound = P.responses > 0 && trace.meta.reportedCost == null;
945
+ let missing = null;
946
+ if (lowerBound && P.estimate) {
947
+ missing = 0;
948
+ for (const r of trace.requests) { if (r.usageFinal !== false) continue; const rate = rateFor(r.model, rates); if (rate) missing += Math.max(0, P.estimate.median - r.usage.output) * rate.out / 1e6; }
949
+ }
905
950
  return {
951
+ lowerBound, partialResponses: P.responses, missing,
906
952
  total: known ? total : null, complete, byRequest, byModel, byAgent, unknownModels: Array.from(unknownModels),
907
953
  reported: trace.meta.reportedCost, source: trace.meta.reportedCost != null ? 'reported' : (known ? 'estimated' : 'unknown'),
908
954
  };
@@ -930,6 +976,7 @@
930
976
  '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.',
931
977
  '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.',
932
978
  'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
979
+ 'missing-final-usage': "Not the agent's doing: Claude Code did not write these subagent responses' final usage to disk. Treat the transcript cost as a floor and check it against Claude Code's own total (/cost or the status line) before budgeting from it.",
933
980
  'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
934
981
  'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
935
982
  'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
@@ -1036,7 +1083,9 @@
1036
1083
  out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
1037
1084
  out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
1038
1085
  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' : ''})`);
1039
- 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)' : ''}`, '');
1086
+ 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 ${cost && cost.lowerBound ? 'at least ' : ''}${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}`);
1087
+ if (cost && cost.lowerBound) out.push(`- cost is a floor: ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} never recorded their final output tokens (anthropics/claude-code#93620)${cost.missing != null ? `; estimated ~${fmtInt(T.usagePartial.estimate.missingOutput)} output tokens, ~${usd(cost.missing)} more` : ''}.`);
1088
+ out.push('');
1040
1089
  if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
1041
1090
  else {
1042
1091
  out.push(`## Findings (${findings.length})`, '');
package/src/viewer.html CHANGED
@@ -475,8 +475,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
475
475
  const t = S.trace, T = t.totals, u = T.usage, cost = S.cost;
476
476
  const ctx = u.input + u.cacheRead + u.cacheWrite;
477
477
  const errs = S.findings.filter((f) => f.severity === 'error').length, warns = S.findings.filter((f) => f.severity === 'warn').length, infos = S.findings.filter((f) => f.severity === 'info').length;
478
- const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? fmtUSD(cost.total) : '—';
479
- const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
478
+ const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? (cost.lowerBound ? '≥ ' : '') + fmtUSD(cost.total) : '—';
479
+ const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.lowerBound ? `a floor — ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} missing final usage${cost.missing != null ? ` · ~${fmtUSD(cost.missing)} more` : ''}` : cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
480
480
  const tiles = [
481
481
  ['Wall time', fmtDur(T.wallMs), T.humanIdleMs ? `${fmtDur(T.activeMs)} active · ${fmtDur(T.humanIdleMs)} waiting on you` : (t.meta.hasTimestamps ? 'no human idle' : 'no timestamps in file')],
482
482
  ['Turns', fmtInt(T.turns), `${fmtInt(T.requests)} API requests`],