glassbox-trace 0.11.0 → 0.12.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 +77 -9
- package/dist/glassbox.html +158 -15
- package/package.json +3 -2
- package/src/claims.mjs +270 -36
- package/src/cli.mjs +181 -30
- package/src/fence.mjs +179 -26
- package/src/guard.mjs +5 -3
- package/src/jsonl.mjs +104 -0
- package/src/managed-paths.mjs +32 -0
- package/src/managed.mjs +76 -0
- package/src/memory-audit.mjs +14 -4
- package/src/memory.mjs +158 -7
- package/src/plan.mjs +253 -0
- package/src/statusline.mjs +154 -0
- package/src/trace-core.js +158 -15
package/src/cli.mjs
CHANGED
|
@@ -7,9 +7,10 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { readTextFile } from './textfile.mjs';
|
|
10
|
+
import { readTranscript, fileIncludes } from './jsonl.mjs';
|
|
10
11
|
import { guardResponse } from './guard.mjs';
|
|
11
12
|
import { judgeClaims, claimFindings, claimsText, claimsMarkdown } from './claims.mjs';
|
|
12
|
-
import { NOTES_FILE, ensureMemoryDir, recordSession, memoryNote, noteTold, maskedTrace, recall, recallText, recallMarkdown, memoryReport, memoryText, memoryMarkdown, forget, clearMemory } from './memory.mjs';
|
|
13
|
+
import { NOTES_FILE, ensureMemoryDir, recordSession, memoryNote, noteTold, maskedTrace, recall, recallText, recallMarkdown, memoryReport, memoryText, memoryMarkdown, forget, clearMemory, syncLessonsBlock, toldFromBlock } from './memory.mjs';
|
|
13
14
|
import { maskSecrets } from './fence.mjs';
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const core = require('./trace-core.js');
|
|
@@ -38,7 +39,7 @@ export function findSessions(opts = {}) {
|
|
|
38
39
|
out.sort((a, b) => b.mtime - a.mtime);
|
|
39
40
|
let list = out;
|
|
40
41
|
if (opts.project) list = list.filter((s) => s.project.includes(opts.project));
|
|
41
|
-
if (opts.grep) { const q = opts.grep.toLowerCase(); list = list.filter((s) => { try { return
|
|
42
|
+
if (opts.grep) { const q = opts.grep.toLowerCase(); list = list.filter((s) => { try { return fileIncludes(s.file, q); } catch (e) { return false; } }); }
|
|
42
43
|
if (opts.last) list = list.slice(0, opts.last);
|
|
43
44
|
return list;
|
|
44
45
|
}
|
|
@@ -81,9 +82,11 @@ export function subagentFiles(s) {
|
|
|
81
82
|
return out;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// Each file as { name, text }, or, past streamBytes() (64 MiB), as parsed records read line by line — a transcript
|
|
86
|
+
// past V8's ~512 MB string limit can't be one string (src/jsonl.mjs, DESIGN.md §21).
|
|
84
87
|
export function loadSessionFiles(s) {
|
|
85
|
-
const files = [
|
|
86
|
-
for (const f of subagentFiles(s)) files.push(
|
|
88
|
+
const files = [readTranscript(s.file, s.id + '.jsonl')];
|
|
89
|
+
for (const f of subagentFiles(s)) files.push(readTranscript(f.path, f.name));
|
|
87
90
|
return files;
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -102,19 +105,43 @@ export function analyse(files, rates) {
|
|
|
102
105
|
|
|
103
106
|
// A team rate card: --rates FILE or GLASSBOX_RATES. JSON keyed by model-id prefix, USD per million tokens:
|
|
104
107
|
// { "claude-opus-5": { "in": 4, "out": 20, "read": 0.4, "w5m": 5, "w1h": 8 } }
|
|
105
|
-
// Entries override or extend the built-in card; cache-write rates default to 1.25× / 2× input.
|
|
108
|
+
// Entries override or extend the built-in card; cache-write rates default to 1.25× / 2× input. An optional
|
|
109
|
+
// "over": { "at": 100000, "in": …, "out": …, "read": … } prices requests whose prompt is over `at` tokens.
|
|
106
110
|
export function loadRates(file) {
|
|
107
111
|
if (!file) return null;
|
|
108
112
|
let json; try { json = JSON.parse(readTextFile(String(file))); } catch (e) { throw new Error(`--rates ${file}: ${e.code === 'ENOENT' ? 'no such file' : 'not valid JSON'}`); }
|
|
109
113
|
if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`--rates ${file}: expected an object keyed by model id`);
|
|
110
114
|
const out = Object.assign({}, core.RATES);
|
|
115
|
+
const card = (r) => ({ in: r.in, out: r.out, read: r.read, w5m: typeof r.w5m === 'number' ? r.w5m : r.in * 1.25, w1h: typeof r.w1h === 'number' ? r.w1h : r.in * 2 });
|
|
116
|
+
const valid = (r) => r && ['in', 'out', 'read'].every((k) => typeof r[k] === 'number' && r[k] >= 0);
|
|
111
117
|
for (const [model, r] of Object.entries(json)) {
|
|
112
|
-
if (!
|
|
113
|
-
|
|
118
|
+
if (!valid(r)) throw new Error(`--rates ${file}: "${model}" needs numeric in, out and read (USD per million tokens)`);
|
|
119
|
+
// A user's row covers its whole family: built-in rows that are longer prefixes of it (claude-sonnet-4-5 under
|
|
120
|
+
// claude-sonnet-4) would otherwise win the longest-prefix match and quietly ignore the user's price.
|
|
121
|
+
for (const k of Object.keys(core.RATES)) if (k !== model && k.startsWith(model) && !(k in json)) delete out[k];
|
|
122
|
+
out[model] = card(r);
|
|
123
|
+
if (r.over !== undefined) {
|
|
124
|
+
if (!valid(r.over) || !(typeof r.over.at === 'number' && r.over.at > 0)) throw new Error(`--rates ${file}: "${model}".over needs a positive "at" (prompt tokens) and numeric in, out and read`);
|
|
125
|
+
out[model].over = Object.assign(card(r.over), { at: r.over.at });
|
|
126
|
+
}
|
|
114
127
|
}
|
|
128
|
+
// Marked, not enumerable: the age check skips a user's card, and the endpoint in a Bedrock id doesn't add 10%
|
|
129
|
+
// on top of a row the user priced from their own cloud sheet.
|
|
130
|
+
Object.defineProperty(out, '_custom', { value: true });
|
|
131
|
+
Object.defineProperty(out, '_userKeys', { value: new Set(Object.keys(json)) });
|
|
115
132
|
return out;
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
// --platform anthropic|bedrock|vertex, --endpoint global|regional, --geo us: how a cloud deployment is billed.
|
|
136
|
+
export function pricingFromFlags(flags, rates) {
|
|
137
|
+
const pick = (k, allowed) => { if (flags[k] === undefined) return undefined; const v = String(flags[k]).toLowerCase(); if (!allowed.includes(v)) throw new Error(`--${k} must be ${allowed.join(', ').replace(/, ([^,]*)$/, ' or $1')} (got "${flags[k]}")`); return v; };
|
|
138
|
+
const p = { platform: pick('platform', ['anthropic', 'bedrock', 'vertex']), endpoint: pick('endpoint', ['global', 'regional']), geo: pick('geo', ['global', 'us']) };
|
|
139
|
+
if (p.geo === 'global') delete p.geo;
|
|
140
|
+
if (!p.platform && !p.endpoint && !p.geo) return rates;
|
|
141
|
+
if (p.geo && p.platform && p.platform !== 'anthropic') throw new Error('--geo us is a first-party setting (inference_geo); on Bedrock or Vertex use --endpoint regional');
|
|
142
|
+
return core.withPricing(rates, p);
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
// "90m", "1h", "2d" → milliseconds.
|
|
119
146
|
export function parseSince(s) {
|
|
120
147
|
const m = String(s).trim().match(/^(\d+(?:\.\d+)?)\s*(m|min|h|d)$/i);
|
|
@@ -122,6 +149,16 @@ export function parseSince(s) {
|
|
|
122
149
|
return +m[1] * ({ m: 60e3, min: 60e3, h: 3600e3, d: 86400e3 })[m[2].toLowerCase()];
|
|
123
150
|
}
|
|
124
151
|
|
|
152
|
+
// The structure-only copy the viewer's "Export redacted" makes: timestamps, usage and tool names kept, every prompt,
|
|
153
|
+
// input, result and assistant text blanked. A line that isn't JSON is dropped rather than kept as it was.
|
|
154
|
+
export function redactFiles(files) {
|
|
155
|
+
return files.map((f) => {
|
|
156
|
+
if (/\.json$/i.test(f.name)) { try { return { name: f.name, text: JSON.stringify(core.redact([JSON.parse(f.text)])[0]) }; } catch (e) { return { name: f.name, text: '{}' }; } }
|
|
157
|
+
const recs = []; for (const line of f.text.split('\n')) { if (!line.trim()) continue; try { recs.push(JSON.parse(line)); } catch (e) { } }
|
|
158
|
+
return { name: f.name, text: core.toJsonl(core.redact(recs)) };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
const safe = (s) => s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--');
|
|
126
163
|
export function embed(files, opts = {}) {
|
|
127
164
|
const template = opts.template || path.join(ROOT, 'dist/glassbox.html');
|
|
@@ -192,6 +229,10 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
|
|
|
192
229
|
// Per-file use, keyed when a legend is given; with --redact and no legend the paths would leak, so it is omitted.
|
|
193
230
|
if (legend || !redact) summary.files = core.fileStats(trace).map((r) => ({ key: legend ? legend.keyFor(r.path) : r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agents, chars: r.chars }));
|
|
194
231
|
if (claims) summary.claims = claims.summary; // said vs did, in one line of numbers (additive; schema stays 2)
|
|
232
|
+
// Is the rate card right for this session's models and date? opts.rates is a --rates card (null = built-in).
|
|
233
|
+
summary.rateCard = core.rateCardCheck(m.models, opts.rates || undefined, { latest: m.end, custom: !!(opts.rates && opts.rates._custom) });
|
|
234
|
+
const rateLine = core.rateCardLine(summary.rateCard);
|
|
235
|
+
summary.pricing = core.pricingNote(m.models, opts.rates || undefined);
|
|
195
236
|
const findingJson = (f) => {
|
|
196
237
|
const ev = f.evidence || {};
|
|
197
238
|
const paths = Array.from(new Set((ev.toolCallIds || []).map((id) => { const c = trace.toolCalls.find((x) => x.id === id); return c ? core.filePathOf(c) : null; }).filter(Boolean)));
|
|
@@ -207,13 +248,15 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
|
|
|
207
248
|
lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
|
|
208
249
|
lines.push(` wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
|
|
209
250
|
lines.push(` context served ${fi(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} tokens (${T.cacheHitRatio != null ? Math.round(T.cacheHitRatio * 100) + '% cached' : 'no usage'}) · output ${fi(T.usage.output)} (${fi(T.usage.thinking)} thinking) · est. cost ${summary.costLowerBound ? '≥ ' : ''}${usd(summary.cost)}${summary.costLowerBound ? ' (floor)' : ''}`);
|
|
251
|
+
if (summary.pricing) lines.push(' ' + summary.pricing);
|
|
252
|
+
if (rateLine) lines.push(' ' + rateLine);
|
|
210
253
|
lines.push('');
|
|
211
254
|
if (!findings.length) lines.push(' no findings');
|
|
212
255
|
for (const g of collapseFindings(findings)) lines.push(' ' + findingLine(g));
|
|
213
256
|
if (redact) lines.push('', ' (redacted: prompt text, tool inputs and result text blanked)');
|
|
214
257
|
lines.push('');
|
|
215
258
|
lines.push(failing.length ? ` FAIL: ${failing.length} finding${failing.length > 1 ? 's' : ''} at or above "${failOn}"` : ` OK: nothing at or above "${failOn}"`);
|
|
216
|
-
const md = core.reportMarkdown(trace, findings, cost, { redact, maxEvidence: opts.maxEvidence });
|
|
259
|
+
const md = core.reportMarkdown(trace, findings, cost, { redact, maxEvidence: opts.maxEvidence }) + [summary.pricing, rateLine].filter(Boolean).map((l) => `\n> ${l}\n`).join('');
|
|
217
260
|
return { text: lines.join('\n'), json, markdown: md, failed: failing.length > 0 };
|
|
218
261
|
}
|
|
219
262
|
|
|
@@ -266,10 +309,21 @@ function undeliveredFindings(sessionId, top, stateDir) {
|
|
|
266
309
|
return fresh;
|
|
267
310
|
}
|
|
268
311
|
|
|
312
|
+
// --memory: where the lessons the agent reads live. agent (default): a block in its own memory (auto memory's
|
|
313
|
+
// MEMORY.md, else CLAUDE.local.md); glassbox: 0.11's note from the SessionStart hook.
|
|
314
|
+
export function memoryMode(v) {
|
|
315
|
+
if (v == null || v === true || v === '') return 'agent';
|
|
316
|
+
const m = String(v).toLowerCase();
|
|
317
|
+
if (m !== 'agent' && m !== 'glassbox') throw new Error(`--memory must be agent or glassbox (got "${v}")`);
|
|
318
|
+
return m;
|
|
319
|
+
}
|
|
269
320
|
export function hookResponse(input, opts = {}) {
|
|
270
321
|
const failOn = opts.failOn || 'warn';
|
|
271
322
|
const feedback = !!opts.feedback;
|
|
272
|
-
|
|
323
|
+
// One memory (0.12): with --memory agent (the default) the lessons live in the agent's own memory and the
|
|
324
|
+
// SessionStart hook says nothing; --memory glassbox keeps 0.11's note.
|
|
325
|
+
const memMode = opts.memory || 'agent';
|
|
326
|
+
if (input && input.hook_event_name === 'SessionStart') return opts.context && memMode === 'glassbox' ? sessionStartResponse(input) : { suppressOutput: true };
|
|
273
327
|
// PreToolUse: null means "say nothing" (normal permission flow), never an approval.
|
|
274
328
|
if (input && input.hook_event_name === 'PreToolUse') return opts.guard ? guardResponse(input) : null;
|
|
275
329
|
// Second Stop after a feedback block: record what the agent said it would do differently, then let it stop.
|
|
@@ -281,7 +335,7 @@ export function hookResponse(input, opts = {}) {
|
|
|
281
335
|
if (!input || !input.transcript_path || !fs.existsSync(input.transcript_path)) return { systemMessage: 'Glassbox: no transcript_path in hook input.', suppressOutput: true };
|
|
282
336
|
const s = sessionFromTranscriptPath(input.transcript_path);
|
|
283
337
|
const res = analyse(loadSessionFiles(s), opts.rates);
|
|
284
|
-
const rep = checkReport(res, { failOn });
|
|
338
|
+
const rep = checkReport(res, { failOn, rates: opts.rates });
|
|
285
339
|
const T = res.trace.totals;
|
|
286
340
|
const usd = (v) => v == null ? '' : ' · ' + (v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2));
|
|
287
341
|
const top = res.findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
|
|
@@ -298,7 +352,12 @@ export function hookResponse(input, opts = {}) {
|
|
|
298
352
|
writeNotes(input.cwd, `# Glassbox: last session in this project\n\n${when} UTC · session ${String(input.session_id || s.id).slice(0, 8)} · ${head.replace(/^Glassbox · /, '')}\n\n${body.trim()}\n`);
|
|
299
353
|
} else if (file && fs.existsSync(file)) fs.unlinkSync(file); // a clean session: don't keep old advice around
|
|
300
354
|
// The memory ledger: this session's digest and lessons. A failure here must not cost the summary.
|
|
301
|
-
try {
|
|
355
|
+
try {
|
|
356
|
+
const sid = input.session_id || s.id;
|
|
357
|
+
if (memMode === 'agent') toldFromBlock(input.cwd, sid, { home: opts.home });
|
|
358
|
+
recordSession(input.cwd, res, { sessionId: sid, failOn });
|
|
359
|
+
if (memMode === 'agent') syncLessonsBlock(input.cwd, { home: opts.home });
|
|
360
|
+
} catch (e) { out.systemMessage += `\nGlassbox memory: ${e.message}`; }
|
|
302
361
|
}
|
|
303
362
|
// Feedback loop: only on Stop, never while continuing from our own block (stop_hook_active), and each
|
|
304
363
|
// finding only once per session. stop_hook_active resets every turn, so without the delivered set a
|
|
@@ -311,6 +370,9 @@ export function hookResponse(input, opts = {}) {
|
|
|
311
370
|
return out;
|
|
312
371
|
}
|
|
313
372
|
|
|
373
|
+
export function readStdinText() {
|
|
374
|
+
return new Promise((resolve) => { let buf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (d) => { buf += d; }); process.stdin.on('end', () => resolve(buf)); if (process.stdin.isTTY) resolve(''); });
|
|
375
|
+
}
|
|
314
376
|
export function readStdinJson() {
|
|
315
377
|
return new Promise((resolve) => { let buf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (d) => { buf += d; }); process.stdin.on('end', () => { try { resolve(JSON.parse(buf || '{}')); } catch (e) { resolve({}); } }); if (process.stdin.isTTY) resolve({}); });
|
|
316
378
|
}
|
|
@@ -324,19 +386,28 @@ export function installHook(opts = {}) {
|
|
|
324
386
|
let settings = {};
|
|
325
387
|
if (fs.existsSync(file)) { try { settings = JSON.parse(readTextFile(file)); } catch (e) { throw new Error(`${file} is not valid JSON — fix it first (nothing was changed)`); } fs.copyFileSync(file, file + '.glassbox-backup'); }
|
|
326
388
|
settings.hooks = settings.hooks || {};
|
|
327
|
-
const
|
|
328
|
-
const events = opts.events || ['Stop', ...(opts.context ? ['SessionStart'] : []), ...(opts.guard ? ['PreToolUse'] : [])];
|
|
389
|
+
const { command: cmd, events, entries } = hookEntries(opts);
|
|
329
390
|
for (const ev of events) {
|
|
330
391
|
const list = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
|
|
331
392
|
const kept = list.filter((entry) => !(entry && Array.isArray(entry.hooks) && entry.hooks.some((h) => h && typeof h.command === 'string' && HOOK_RE.test(h.command))));
|
|
332
|
-
|
|
333
|
-
kept.push({ hooks: [{ type: 'command', command: cmd, timeout: ev === 'PreToolUse' ? 10 : 60 }] });
|
|
393
|
+
kept.push(entries[ev]);
|
|
334
394
|
settings.hooks[ev] = kept;
|
|
335
395
|
}
|
|
336
396
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
337
397
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
338
398
|
return { file, command: cmd, events };
|
|
339
399
|
}
|
|
400
|
+
// The hook command and one settings entry per event, shared by `hook install` and `managed`.
|
|
401
|
+
export function hookEntries(opts = {}) {
|
|
402
|
+
// --memory glassbox (0.11's SessionStart note) is the only mode that needs SessionStart; the default writes the
|
|
403
|
+
// lessons into the agent's own memory from the Stop hook (0.12, DESIGN.md §21).
|
|
404
|
+
const legacy = opts.context && opts.memory === 'glassbox';
|
|
405
|
+
const command = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${legacy ? ' --memory glassbox' : ''}${opts.guard ? ' --guard' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
|
|
406
|
+
const events = opts.events || ['Stop', ...(legacy ? ['SessionStart'] : []), ...(opts.guard ? ['PreToolUse'] : [])];
|
|
407
|
+
// PreToolUse runs before every tool call, so it gets a short timeout; no matcher means all tools.
|
|
408
|
+
const entries = {}; for (const ev of events) entries[ev] = { hooks: [{ type: 'command', command, timeout: ev === 'PreToolUse' ? 10 : 60 }] };
|
|
409
|
+
return { command, events, entries };
|
|
410
|
+
}
|
|
340
411
|
// The guard runs before every tool call, so it can't go through npx (seconds per call). Point the hook at
|
|
341
412
|
// this install's own entry point instead — unless this *is* a throwaway npx cache copy.
|
|
342
413
|
export function guardCommand(root) {
|
|
@@ -361,7 +432,7 @@ export function parseArgs(argv) {
|
|
|
361
432
|
const args = { _: [], flags: {} };
|
|
362
433
|
for (let i = 0; i < argv.length; i++) {
|
|
363
434
|
const a = argv[i];
|
|
364
|
-
if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under', 'key', 'limit'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
|
|
435
|
+
if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under', 'key', 'limit', 'memory', 'top', 'plan', 'plan-price', 'bill-day', 'platform', 'endpoint', 'geo', 'markings-file', 'temp', 'dir'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
|
|
365
436
|
else args._.push(a);
|
|
366
437
|
}
|
|
367
438
|
return args;
|
|
@@ -372,7 +443,7 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
|
|
|
372
443
|
glassbox open the newest session in your browser
|
|
373
444
|
glassbox list [--last N] [--grep TEXT] [--project PATH]
|
|
374
445
|
list sessions, newest first
|
|
375
|
-
glassbox open [ID|FILE] [--out FILE.html] [--no-open]
|
|
446
|
+
glassbox open [ID|FILE] [--out FILE.html] [--no-open] [--redact]
|
|
376
447
|
build a self-contained HTML for a session (id prefix or .jsonl path)
|
|
377
448
|
glassbox check [ID|FILE] [--fail-on error|warn|info] [--format text|json|md] [--redact]
|
|
378
449
|
print findings; exit 1 when any finding is at/above --fail-on (default: error), 2 on a usage error
|
|
@@ -389,11 +460,12 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
|
|
|
389
460
|
same task, two sessions: what changed in time, tokens, cost, tools and findings
|
|
390
461
|
glassbox watch [ID|FILE] [--port N] [--no-open]
|
|
391
462
|
live tail: serves the viewer on 127.0.0.1 and pushes the transcript as it grows
|
|
392
|
-
glassbox hook install [--feedback] [--context] [--guard] [--fail-on warn]
|
|
463
|
+
glassbox hook install [--feedback] [--context [--memory agent|glassbox]] [--guard] [--fail-on warn]
|
|
393
464
|
add a Claude Code Stop hook so every session ends with a Glassbox summary;
|
|
394
465
|
--feedback also hands the findings back to the agent once, so it can learn from them;
|
|
395
466
|
--context keeps a memory in <project>/.glassbox/ (lessons that keep coming back, a digest
|
|
396
|
-
per session
|
|
467
|
+
per session) and writes the lessons into the agent's own memory (auto memory's MEMORY.md,
|
|
468
|
+
else CLAUDE.local.md) as one block; --memory glassbox hands 0.11's note at session start;
|
|
397
469
|
--guard blocks a tool call that already failed twice in a row, unchanged, and says why
|
|
398
470
|
glassbox hook uninstall remove it (a .glassbox-backup of settings.json is kept)
|
|
399
471
|
glassbox collect DIR [--since 30d] [--format text|md|json] [--out FILE] [--top N]
|
|
@@ -414,6 +486,9 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
|
|
|
414
486
|
credential-file reads — with a masked preview and a fingerprint, never the value.
|
|
415
487
|
no target = every session under the home; exit 1 when anything at/above --fail-on was found
|
|
416
488
|
--shred overwrites each value in place with [FENCED:<rule>:<fingerprint>] (no backup)
|
|
489
|
+
--markings also finds export-control markings (classification banners, ITAR / EAR
|
|
490
|
+
legends, CUI, DoD distribution statements B–F, ECCNs); --markings-file FILE adds your
|
|
491
|
+
own ({ id, pattern, flags?, severity? } per entry). Markings are never shredded
|
|
417
492
|
glassbox recall [WORDS…] [--project DIR] [--since 30d] [--limit N] [--format text|md|json] [--out FILE]
|
|
418
493
|
search this project's past sessions: what was asked, written, run, committed and said,
|
|
419
494
|
from the transcripts still on disk and .glassbox/memory.json; no words = the latest ones
|
|
@@ -424,11 +499,33 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
|
|
|
424
499
|
glassbox memory audit [--project DIR] [--format text|md|json] [--out FILE] [--fail-on error|warn|info|none]
|
|
425
500
|
everything loaded before the first prompt (CLAUDE.md files, rules, @imports, auto memory,
|
|
426
501
|
Glassbox's own note) with its token cost, dead imports and paths, duplicates and conflicting rules
|
|
502
|
+
glassbox plan [--since 7d] [--plan pro|max-5x|max-20x|none] [--plan-price N] [--bill-day D] [--top N] [--format text|md|json] [--out FILE] [--redact]
|
|
503
|
+
what a Pro/Max plan carried, at list price: billing months vs the plan price, the last
|
|
504
|
+
7 days by model, rebuilt 5-hour windows (heaviest first) and limit hits. The plan comes
|
|
505
|
+
from ~/.claude.json; Claude Code on this machine only, and not your share of the limits
|
|
506
|
+
glassbox plan --live add the plan's real readings from the status line: the latest 5-hour and weekly %,
|
|
507
|
+
exact windows with their peak %, and how much the week moved while each session ran
|
|
508
|
+
glassbox statusline install [--no-color]
|
|
509
|
+
put the plan's real 5-hour and weekly % in Claude Code's status bar, and log each
|
|
510
|
+
change to ~/.claude/glassbox/limits.jsonl for plan --live; an existing status line
|
|
511
|
+
keeps running in front of it
|
|
512
|
+
glassbox statusline uninstall remove it and put back the status line you had (a .glassbox-backup is kept)
|
|
513
|
+
glassbox managed [--hook [--feedback] [--context [--memory agent|glassbox]] [--guard]] [--statusline] [--temp allow|redact|deny] [--install | --out FILE] [--uninstall] [--command CMD]
|
|
514
|
+
Glassbox for every user on the machine, through Claude Code's managed settings: prints
|
|
515
|
+
the settings, or with --install writes its own drop-in, managed-settings.d/glassbox.json
|
|
516
|
+
(needs an administrator; never edits managed-settings.json). --temp sets GLASSBOX_TEMP
|
|
427
517
|
glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
|
|
428
518
|
|
|
429
519
|
Options --home DIR use DIR instead of ~/.claude (or set GLASSBOX_HOME)
|
|
430
|
-
--rates FILE a JSON rate card for check, compare and hook (or set GLASSBOX_RATES)
|
|
520
|
+
--rates FILE a JSON rate card for check, compare, plan and hook (or set GLASSBOX_RATES)
|
|
521
|
+
--platform anthropic|bedrock|vertex --endpoint global|regional --geo us
|
|
522
|
+
how the requests were billed: regional endpoints add 10% on Claude 4.5 and later (Bedrock
|
|
523
|
+
ids carry it: us.anthropic.… is regional, global.anthropic.… is not); --geo us is first-party
|
|
524
|
+
US-only inference, +10% on Claude 4.6 and later
|
|
431
525
|
--version --help
|
|
526
|
+
Temp open writes the transcript to the temp folder unless --out is given; GLASSBOX_TEMP=redact blanks
|
|
527
|
+
the text first, GLASSBOX_TEMP=deny forbids it (also read from env in the managed settings);
|
|
528
|
+
open --redact does the same for one run
|
|
432
529
|
Browser set GLASSBOX_BROWSER to a command to open HTML files with
|
|
433
530
|
|
|
434
531
|
Nothing leaves your machine. Subagent transcripts next to the session are included automatically.`;
|
|
@@ -465,7 +562,7 @@ export async function main(argv, io = {}) {
|
|
|
465
562
|
if (args.flags.help || cmd === 'help') { out(HELP); return 0; }
|
|
466
563
|
if (args.flags.version) { out(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version); return 0; }
|
|
467
564
|
try {
|
|
468
|
-
const rates = loadRates(args.flags.rates || (io.env || process.env).GLASSBOX_RATES);
|
|
565
|
+
const rates = pricingFromFlags(args.flags, loadRates(args.flags.rates || (io.env || process.env).GLASSBOX_RATES));
|
|
469
566
|
if (cmd === 'list') {
|
|
470
567
|
const list = findSessions({ home, last: args.flags.last ? +args.flags.last : 20, grep: args.flags.grep, project: args.flags.project });
|
|
471
568
|
if (!list.length) { out(noSessionsMessage(home)); return 0; }
|
|
@@ -489,7 +586,7 @@ export async function main(argv, io = {}) {
|
|
|
489
586
|
const since = args.flags.since ? parseSince(args.flags.since) : null;
|
|
490
587
|
const list = findSessions({ home, project: args.flags.project }).filter((s) => since == null || s.mtime >= Date.now() - since);
|
|
491
588
|
const fmtOut = outputFormat(args.flags);
|
|
492
|
-
const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend }) }));
|
|
589
|
+
const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend, rates }) }));
|
|
493
590
|
saveLegend();
|
|
494
591
|
const failed = reps.filter((r) => r.rep.failed).length;
|
|
495
592
|
if (fmtOut === 'json') out(JSON.stringify({ glassbox: core.VERSION, schema: 2, redacted: !!args.flags.redact, legend: !!legend, failOn: args.flags['fail-on'] || 'error', failed: failed > 0, sessions: reps.map((r) => r.rep.json) }, null, 2));
|
|
@@ -503,7 +600,7 @@ export async function main(argv, io = {}) {
|
|
|
503
600
|
}
|
|
504
601
|
if (cmd === 'check') {
|
|
505
602
|
const s = resolveTarget(args._[1], { home });
|
|
506
|
-
const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend });
|
|
603
|
+
const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend, rates });
|
|
507
604
|
saveLegend();
|
|
508
605
|
const fmtOut = outputFormat(args.flags);
|
|
509
606
|
if (fmtOut === 'json') out(JSON.stringify(res.json, null, 2)); else if (fmtOut === 'md') out(res.markdown); else out(res.text);
|
|
@@ -526,12 +623,21 @@ export async function main(argv, io = {}) {
|
|
|
526
623
|
return 0;
|
|
527
624
|
}
|
|
528
625
|
if (cmd === 'open' && !args.flags.watch) {
|
|
626
|
+
// The temp folder is the one place `open` writes without being told where; GLASSBOX_TEMP (or the managed
|
|
627
|
+
// settings' env) can make that redacted-only or forbid it (src/managed.mjs).
|
|
628
|
+
const { tempPolicy } = await import('./managed.mjs');
|
|
629
|
+
const tp = args.flags.out ? null : tempPolicy(io.env || process.env, io.managedDir);
|
|
630
|
+
if (tp && tp.policy === 'deny') throw new Error(`the temp folder is closed to transcripts (GLASSBOX_TEMP=deny, from ${tp.from}): pass --out FILE to choose where the HTML goes`);
|
|
529
631
|
const s = resolveTarget(args._[1], { home });
|
|
530
|
-
const
|
|
632
|
+
const raw = loadSessionFiles(s);
|
|
633
|
+
const big = raw.find((f) => Array.isArray(f.records));
|
|
634
|
+
if (big) throw new Error(`${big.name} is ${(big.bytes / 1048576).toFixed(0)} MB — too large for the browser viewer, which reads a transcript whole. glassbox check ${s.id.slice(0, 8)} reads it line by line.`);
|
|
635
|
+
const redacted = !!args.flags.redact || (tp && tp.policy === 'redact');
|
|
636
|
+
const files = redacted ? redactFiles(raw) : raw;
|
|
531
637
|
const html = embed(files, { template: io.template });
|
|
532
638
|
const outFile = args.flags.out ? path.resolve(args.flags.out) : path.join(os.tmpdir(), `glassbox-${s.id.slice(0, 8)}.html`);
|
|
533
639
|
fs.writeFileSync(outFile, html);
|
|
534
|
-
out(`${outFile} (${files.length} file${files.length > 1 ? 's' : ''}, ${Math.round(html.length / 1024)} KB)`);
|
|
640
|
+
out(`${outFile} (${files.length} file${files.length > 1 ? 's' : ''}, ${Math.round(html.length / 1024)} KB${redacted ? ', redacted' : ''})`);
|
|
535
641
|
if (!args.flags['no-open'] && !io.noOpen) openInBrowser(outFile);
|
|
536
642
|
return 0;
|
|
537
643
|
}
|
|
@@ -572,12 +678,12 @@ export async function main(argv, io = {}) {
|
|
|
572
678
|
}
|
|
573
679
|
if (cmd === 'hook') {
|
|
574
680
|
const sub = args._[1];
|
|
575
|
-
if (sub === 'install') { const r = installHook({ home, feedback: !!args.flags.feedback, context: !!args.flags.context, guard: !!args.flags.guard, failOn: args.flags['fail-on'], command: args.flags.command || (args.flags.guard ? guardCommand(ROOT) : undefined), events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed ${r.events.join(', ')} hook${r.events.length > 1 ? 's' : ''} in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}${args.flags.context ? `; findings are kept in <project>/${NOTES_FILE.replace(/\\/g, '/')} and handed to the next session there` : ''}${args.flags.guard ? '; a call that already failed twice in a row, unchanged, is blocked with the reason' : ''}. Restart Claude Code to pick it up.`); return 0; }
|
|
681
|
+
if (sub === 'install') { const mem = memoryMode(args.flags.memory); const r = installHook({ home, feedback: !!args.flags.feedback, context: !!args.flags.context, memory: mem, guard: !!args.flags.guard, failOn: args.flags['fail-on'], command: args.flags.command || (args.flags.guard ? guardCommand(ROOT) : undefined), events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed ${r.events.join(', ')} hook${r.events.length > 1 ? 's' : ''} in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}${args.flags.context ? (mem === 'agent' ? '; the lessons that keep coming back are kept in the agent\'s own memory (auto memory\'s MEMORY.md, else CLAUDE.local.md), in one block Glassbox rewrites' : `; findings are kept in <project>/${NOTES_FILE.replace(/\\/g, '/')} and handed to the next session there`) : ''}${args.flags.guard ? '; a call that already failed twice in a row, unchanged, is blocked with the reason' : ''}. Restart Claude Code to pick it up.`); return 0; }
|
|
576
682
|
if (sub === 'uninstall') { const r = uninstallHook({ home }); out(`Removed ${r.removed} Glassbox hook${r.removed === 1 ? '' : 's'} from ${r.file}`); return 0; }
|
|
577
683
|
// A hook must never fail loudly: exit 2 from a Stop hook blocks Claude with the error as the reason.
|
|
578
684
|
// A null reply prints nothing, which for PreToolUse means "no decision".
|
|
579
685
|
let reply = null, event = null;
|
|
580
|
-
try { const input = io.stdin !== undefined ? io.stdin : await readStdinJson(); event = input && input.hook_event_name; reply = hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback, context: !!args.flags.context, guard: !!args.flags.guard, rates, stateDir: io.stateDir }); }
|
|
686
|
+
try { const input = io.stdin !== undefined ? io.stdin : await readStdinJson(); event = input && input.hook_event_name; reply = hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback, context: !!args.flags.context, memory: memoryMode(args.flags.memory), guard: !!args.flags.guard, rates, stateDir: io.stateDir, home }); }
|
|
581
687
|
catch (e) { reply = event === 'PreToolUse' ? null : { systemMessage: 'Glassbox: ' + e.message, suppressOutput: true }; }
|
|
582
688
|
if (reply != null) out(JSON.stringify(reply));
|
|
583
689
|
return 0;
|
|
@@ -604,7 +710,7 @@ export async function main(argv, io = {}) {
|
|
|
604
710
|
const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
|
|
605
711
|
const failOn = args.flags['fail-on'] || 'error';
|
|
606
712
|
const fmtOut = outputFormat(args.flags);
|
|
607
|
-
const rep = fence({ home, target: args._[1], since, project: args.flags.project, shred: !!args.flags.shred, keyFile: args.flags.key, sessionsOnly: !!args.flags['sessions-only'] });
|
|
713
|
+
const rep = fence({ home, target: args._[1], since, project: args.flags.project, shred: !!args.flags.shred, keyFile: args.flags.key, sessionsOnly: !!args.flags['sessions-only'], markings: !!args.flags.markings, markingsFile: args.flags['markings-file'] });
|
|
608
714
|
const failed = failedAt(rep, failOn);
|
|
609
715
|
const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? fenceMarkdown(rep) : fenceText(rep);
|
|
610
716
|
if (args.flags.out) { const f = path.resolve(args.flags.out); fs.writeFileSync(f, text); out(`${f} (${rep.scanned.files} files, ${rep.findings.length} findings${rep.shredded ? `, ${rep.shredded.values} values shredded` : ''})`); }
|
|
@@ -652,12 +758,57 @@ export async function main(argv, io = {}) {
|
|
|
652
758
|
out(keys.length ? `Forgot ${keys.length} lesson${keys.length === 1 ? '' : 's'} in ${project}:\n ${keys.join('\n ')}` : `No lesson matches "${what}" in ${project} — glassbox memory lists them`);
|
|
653
759
|
return keys.length ? 0 : 1;
|
|
654
760
|
}
|
|
655
|
-
if (sub === 'clear') { const removed = clearMemory(project); out(removed.length ? `Removed:\n ${removed.join('\n ')}` : `No Glassbox memory in ${project}`); return 0; }
|
|
761
|
+
if (sub === 'clear') { const removed = clearMemory(project, { home }); out(removed.length ? `Removed:\n ${removed.join('\n ')}` : `No Glassbox memory in ${project}`); return 0; }
|
|
656
762
|
if (sub !== 'show') throw new Error(`memory: unknown subcommand "${sub}" (show, forget, clear, audit)`);
|
|
657
|
-
const rep = memoryReport({ project });
|
|
763
|
+
const rep = memoryReport({ project, home });
|
|
658
764
|
writeOut(fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? memoryMarkdown(rep) : memoryText(rep), `${rep.sessions} sessions, ${rep.summary.active} active lessons`);
|
|
659
765
|
return 0;
|
|
660
766
|
}
|
|
767
|
+
if (cmd === 'plan') {
|
|
768
|
+
// What a Pro/Max plan carried: list price by billing month, last 7 days, rebuilt 5-hour windows, limit hits.
|
|
769
|
+
const { plan, planText, planMarkdown } = await import('./plan.mjs');
|
|
770
|
+
const num = (k) => { if (args.flags[k] === undefined) return null; const v = Number(args.flags[k]); if (!Number.isFinite(v) || v < 0) throw new Error(`--${k} must be a number (got "${args.flags[k]}")`); return v; };
|
|
771
|
+
const fmtOut = outputFormat(args.flags);
|
|
772
|
+
const rep = plan({ home, now: io.now, since: args.flags.since ? parseSince(args.flags.since) : undefined, plan: args.flags.plan !== undefined ? String(args.flags.plan) : undefined, planPrice: num('plan-price'), billDay: num('bill-day'), top: num('top') || 5, redact: !!args.flags.redact, rates, live: !!args.flags.live });
|
|
773
|
+
const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? planMarkdown(rep) : planText(rep);
|
|
774
|
+
if (args.flags.out) { const f = path.resolve(args.flags.out); fs.writeFileSync(f, text); out(`${f} (${rep.sessions} sessions, ${rep.requests} requests)`); }
|
|
775
|
+
else out(text.replace(/\n$/, ''));
|
|
776
|
+
return 0;
|
|
777
|
+
}
|
|
778
|
+
if (cmd === 'managed') {
|
|
779
|
+
// Glassbox for every user on the machine: a drop-in in Claude Code's managed-settings.d/, printed or written.
|
|
780
|
+
const mg = await import('./managed.mjs');
|
|
781
|
+
const dir = args.flags.dir ? String(args.flags.dir) : mg.managedDir(process.platform, io.env || process.env);
|
|
782
|
+
const file = args.flags.out ? path.resolve(String(args.flags.out)) : mg.dropInFile(dir);
|
|
783
|
+
if (args.flags.uninstall) { const r = mg.removeDropIn({ file }); out(r.removed ? `Removed ${r.file}` : `Nothing to remove: ${r.file} is not there`); return 0; }
|
|
784
|
+
const command = args.flags.command ? String(args.flags.command) : guardCommand(ROOT);
|
|
785
|
+
const frag = mg.managedFragment({ command, hook: !!args.flags.hook, feedback: !!args.flags.feedback, context: !!args.flags.context, memory: memoryMode(args.flags.memory), guard: !!args.flags.guard, failOn: args.flags['fail-on'], statusline: !!args.flags.statusline, noColor: !!args.flags['no-color'], temp: args.flags.temp !== undefined ? String(args.flags.temp).toLowerCase() : undefined });
|
|
786
|
+
if ((frag.hooks || frag.statusLine) && mg.userScoped(command)) err(`managed: the command points into ${os.homedir()}, which the machine's other users can't run. Install Glassbox machine-wide (npm i -g with a shared prefix) or pass --command.`);
|
|
787
|
+
if (!args.flags.install && !args.flags.out) { out(JSON.stringify(frag, null, 2)); err(`managed: printed only. To apply it for every user, save it as ${mg.dropInFile(dir)} (needs an administrator), or run this again with --install.`); return 0; }
|
|
788
|
+
let r; try { r = mg.writeDropIn(frag, { file }); } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') throw new Error(`can't write ${file}: run this from an administrator prompt (or with sudo), or print the fragment and hand it to whoever manages the machine`); throw e; }
|
|
789
|
+
out(`Wrote ${r.file}${frag.hooks ? `\n hooks: ${Object.keys(frag.hooks).join(', ')}` : ''}${frag.statusLine ? `\n statusLine: ${frag.statusLine.command}` : ''}${frag.env ? `\n env: GLASSBOX_TEMP=${frag.env.GLASSBOX_TEMP}` : ''}\nClaude Code reads it at start for every user on this machine. Remove with: glassbox managed --uninstall`);
|
|
790
|
+
return 0;
|
|
791
|
+
}
|
|
792
|
+
if (cmd === 'statusline') {
|
|
793
|
+
// What Claude Code runs on every status refresh (JSON on stdin), or install / uninstall in settings.json.
|
|
794
|
+
const sl = await import('./statusline.mjs');
|
|
795
|
+
const sub = args._[1];
|
|
796
|
+
if (sub === 'install') {
|
|
797
|
+
const r = sl.installStatusLine({ home, command: args.flags.command || guardCommand(ROOT), noColor: !!args.flags['no-color'] });
|
|
798
|
+
out(`Installed the Glassbox status line in ${r.file}\n ${r.command}\n${r.kept ? `Your previous status line keeps running in front of it: ${r.kept}\n` : ''}Readings go to ${sl.limitsFile(home)}; \`glassbox plan --live\` reads them. Restart Claude Code to pick it up.`);
|
|
799
|
+
return 0;
|
|
800
|
+
}
|
|
801
|
+
if (sub === 'uninstall') { const r = sl.uninstallStatusLine({ home }); out(r.removed ? `Removed the Glassbox status line from ${r.file}${r.restored ? `; restored ${r.restored}` : ''}` : `No Glassbox status line in ${r.file}`); return 0; }
|
|
802
|
+
// Never fail loudly: a status bar that shows an error on every refresh is worse than a short one.
|
|
803
|
+
let line;
|
|
804
|
+
try {
|
|
805
|
+
const raw = io.stdin !== undefined ? (typeof io.stdin === 'string' ? io.stdin : JSON.stringify(io.stdin)) : await readStdinText();
|
|
806
|
+
let input = {}; try { input = JSON.parse(raw || '{}'); } catch (e) { }
|
|
807
|
+
line = sl.statusline(input, { home, now: io.now, raw, color: !(args.flags['no-color'] || (io.env || process.env).NO_COLOR) });
|
|
808
|
+
} catch (e) { line = 'glassbox: ' + e.message; }
|
|
809
|
+
out(line);
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
661
812
|
err('Unknown command: ' + cmd + '\n'); out(HELP); return 2;
|
|
662
813
|
} catch (e) { err('glassbox: ' + e.message); return 2; }
|
|
663
814
|
}
|