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/README.md +37 -3
- package/dist/glassbox.html +55 -6
- package/package.json +4 -2
- package/src/adhere.mjs +14 -6
- package/src/claims.mjs +3 -3
- package/src/cli.mjs +72 -16
- package/src/collect.mjs +6 -2
- package/src/fence.mjs +19 -0
- package/src/memory-audit.mjs +236 -0
- package/src/memory.mjs +428 -0
- package/src/trace-core.js +53 -4
- package/src/viewer.html +2 -2
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.
|
|
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`],
|