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/memory.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { maskSecrets } from './fence.mjs';
|
|
|
17
17
|
import { segments, actionRe, isShell, projectSessions } from './adhere.mjs';
|
|
18
18
|
import { TEST_CMD, runFailed } from './claims.mjs';
|
|
19
19
|
import { loadSessionFiles } from './cli.mjs';
|
|
20
|
+
import { autoMemoryDir } from './memory-audit.mjs';
|
|
20
21
|
const require = createRequire(import.meta.url);
|
|
21
22
|
const core = require('./trace-core.js');
|
|
22
23
|
|
|
@@ -47,14 +48,25 @@ export const cleanCmd = (seg) => String(seg).replace(/^timeout\s+(?:-\S+\s+)*\d+
|
|
|
47
48
|
|
|
48
49
|
// What a shell command does, in a word or two: the first segment that isn't navigation, its program and subcommand
|
|
49
50
|
// ("npm test", "npm run e2e", "git push", "python -m pytest").
|
|
51
|
+
// A word in a lesson key must look like a program or a subcommand, never like a password or a token: a key is
|
|
52
|
+
// written into the agent's own memory (0.12 review: `echo <pw> | sudo -S apt-get …` became `Bash echo <pw>`).
|
|
53
|
+
// Lowercase words joined by : . _ - are names (build:es2015, test:e2e2, build-v2, python3.11, webpack5); mixed case
|
|
54
|
+
// with digits, or anything long, is not kept.
|
|
55
|
+
const NAME_RE = /^[a-z][a-z0-9]*(?:[:._-][a-z0-9]+)*$/;
|
|
56
|
+
const secretish = (w) => w.length > 40 || (!NAME_RE.test(w) && (w.length > 24 || (w.length >= 8 && /\d/.test(w) && /[A-Za-z]/.test(w)) || (/[A-Z]/.test(w) && /[a-z]/.test(w) && /\d/.test(w))));
|
|
50
57
|
export function commandHead(cmd) {
|
|
51
|
-
|
|
52
|
-
const
|
|
58
|
+
// Not navigation, and not a segment that only feeds data to the next one (echo / printf / yes / cat <<).
|
|
59
|
+
const seg = cleanCmd(segments(cmd).find((s) => !/^(?:cd|pushd|popd|export|set|source|\.|echo|printf|yes|true|false|:)(?:\s|$)/.test(s) && !/^cat\s+<</.test(s)) || '');
|
|
60
|
+
let toks = seg.split(/\s+/).filter(Boolean);
|
|
61
|
+
while (toks.length && /^-/.test(toks[0])) toks = toks.slice(/^-[ugpCDhr]$/.test(toks[0]) ? 2 : 1); // what's left of `sudo -u bob …`
|
|
62
|
+
if (!toks.length) return '';
|
|
53
63
|
const prog = toks[0].replace(/^.*[\\/]/, '');
|
|
54
|
-
if (
|
|
64
|
+
if (!/^[\w.+-]+$/.test(prog) || secretish(prog)) return '';
|
|
65
|
+
if (/^python(?:3(?:\.\d+)?)?$/.test(prog) && toks[1] === '-m' && toks[2]) return `${prog} -m ${toks[2]}`;
|
|
55
66
|
let i = 1; if (prog === 'git') while (i < toks.length && /^-/.test(toks[i])) i += /^-[Cc]$/.test(toks[i]) ? 2 : 1;
|
|
56
67
|
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 (
|
|
68
|
+
if (sub && secretish(sub)) sub = '';
|
|
69
|
+
if (/^(?:run|exec|x|dlx)$/.test(sub) && toks[i + 1] && /^[\w:.-]+$/.test(toks[i + 1]) && !secretish(toks[i + 1])) sub += ' ' + toks[i + 1];
|
|
58
70
|
return sub ? `${prog} ${sub}` : prog;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -298,25 +310,158 @@ export function forget(cwd, what) {
|
|
|
298
310
|
return keys;
|
|
299
311
|
}
|
|
300
312
|
|
|
301
|
-
export function clearMemory(cwd) {
|
|
313
|
+
export function clearMemory(cwd, opts = {}) {
|
|
302
314
|
const removed = [];
|
|
303
315
|
for (const f of [MEMORY_FILE, NOTES_FILE]) { const p = path.join(cwd, f); if (fs.existsSync(p)) { fs.unlinkSync(p); removed.push(p); } }
|
|
316
|
+
// The lessons block, wherever it was written (auto memory may have been switched on or off since).
|
|
317
|
+
for (const file of lessonsFiles(cwd, opts)) { if (writeLessonsBlock(file, null).changed) removed.push(`${file} (Glassbox lessons block)`); }
|
|
304
318
|
return removed;
|
|
305
319
|
}
|
|
306
320
|
|
|
321
|
+
// ---------------------------------------------------------------------------------------------
|
|
322
|
+
// One memory (0.12, DESIGN.md §21). The lessons the agent should act on go into the memory it already reads — the
|
|
323
|
+
// auto memory MEMORY.md when auto memory is on and its folder exists, else <project>/CLAUDE.local.md; never the
|
|
324
|
+
// shared CLAUDE.md — as one block Glassbox owns and rewrites at every Stop. The block is a projection of the ledger
|
|
325
|
+
// (active lessons, worst first, the advice once per rule), so it can't drift from it; text outside the markers is
|
|
326
|
+
// never touched. The end marker carries the keys, so the next session's first Stop knows what it was told.
|
|
327
|
+
// ---------------------------------------------------------------------------------------------
|
|
328
|
+
export const BLOCK_START = '<!-- glassbox:lessons:start';
|
|
329
|
+
export const BLOCK_END = '<!-- glassbox:lessons:end';
|
|
330
|
+
export const BLOCK_MAX = 1800;
|
|
331
|
+
export function lessonsTarget(cwd, opts = {}) {
|
|
332
|
+
const am = autoMemoryDir({ project: cwd, home: opts.home, userHome: opts.userHome });
|
|
333
|
+
if (am.enabled && am.dir) return { file: path.join(am.dir, 'MEMORY.md'), kind: 'auto-memory' };
|
|
334
|
+
return { file: path.join(path.resolve(cwd), 'CLAUDE.local.md'), kind: 'local' };
|
|
335
|
+
}
|
|
336
|
+
function lessonsFiles(cwd, opts) {
|
|
337
|
+
const am = autoMemoryDir({ project: cwd, home: opts.home, userHome: opts.userHome });
|
|
338
|
+
return [am.dir ? path.join(am.dir, 'MEMORY.md') : null, path.join(path.resolve(cwd), 'CLAUDE.local.md')].filter((f) => f && fs.existsSync(f));
|
|
339
|
+
}
|
|
340
|
+
export function lessonsBlock(led, opts = {}) {
|
|
341
|
+
const o = Object.assign({}, MEMORY_DEFAULTS, opts), n = led.sessions.length;
|
|
342
|
+
let shown = lessonStats(led, o).filter((l) => l.active).slice(0, o.lessons);
|
|
343
|
+
const build = (list) => {
|
|
344
|
+
const advised = new Set();
|
|
345
|
+
const lines = list.map((l) => {
|
|
346
|
+
const adv = !advised.has(l.rule) && core.ADVICE[l.rule]; advised.add(l.rule);
|
|
347
|
+
const howOften = n === 1 ? 'in the only session so far' : `in ${l.sessions} of the last ${n} sessions`;
|
|
348
|
+
// No marker can be forged from a key, title or path: an HTML comment opener is broken with a zero-width non-joiner.
|
|
349
|
+
return '- ' + clip(`${maskSecrets(l.key)} — ${howOften}: ${maskSecrets(l.title)}.${adv ? ` Next time: ${adv}` : ''}`, 320).replace(/<!--/g, '<\u200c!--');
|
|
350
|
+
});
|
|
351
|
+
return [`${BLOCK_START} (rewritten by the Glassbox Stop hook from this project's transcripts; edit outside these markers; \`glassbox memory forget <lesson>\` drops one) -->`,
|
|
352
|
+
'## Glassbox: what keeps going wrong in this project', '', ...lines,
|
|
353
|
+
`${BLOCK_END} keys=${list.map((l) => encodeURIComponent(l.key)).join(',')} -->`, ''].join('\n');
|
|
354
|
+
};
|
|
355
|
+
if (!shown.length) return null;
|
|
356
|
+
let text = build(shown);
|
|
357
|
+
while (text.length > BLOCK_MAX && shown.length > 1) { shown = shown.slice(0, -1); text = build(shown); }
|
|
358
|
+
return { text, keys: shown.map((l) => l.key) };
|
|
359
|
+
}
|
|
360
|
+
export function blockKeys(text) {
|
|
361
|
+
const t = String(text || ''); const b = findBlocks(t.charCodeAt(0) === 0xfeff ? t.slice(1) : t).blocks[0];
|
|
362
|
+
return b ? b.keys.split(',').filter(Boolean).map((k) => { try { return decodeURIComponent(k); } catch (e) { return k; } }) : [];
|
|
363
|
+
}
|
|
364
|
+
// Put `body` (a block, or null to remove it) into the file. Markers count only as whole lines outside ``` / ~~~
|
|
365
|
+
// fences, so a marker quoted in prose, inline code or a fenced example is never taken for the block (0.12 review);
|
|
366
|
+
// a start line with no end line after it replaces only itself. In place when a block is there (any extra complete
|
|
367
|
+
// blocks are dropped); under a leading `# ` title in an auto memory index, so it sits inside the 200-line cut; at the
|
|
368
|
+
// end of anything else. Line endings and a BOM follow the file. Written through a symlink, with the file's mode, via
|
|
369
|
+
// a temp file unique to this process; a file we can't write is left alone. Concurrent writers each start from the
|
|
370
|
+
// whole file, so the last rename wins without losing the user's lines.
|
|
371
|
+
const START_LINE = /^<!-- glassbox:lessons:start\b.*-->[ \t]*\r?$/;
|
|
372
|
+
const END_LINE = /^<!-- glassbox:lessons:end keys=(\S*) -->[ \t]*\r?$/;
|
|
373
|
+
// CommonMark fences: an opener is 3+ backticks or tildes (backtick openers can't have a backtick in the info
|
|
374
|
+
// string); the closer is the same character, at least as long, with nothing after it. A fence still open at the end
|
|
375
|
+
// of the file runs to the end in Markdown, but anything after it may be ours, so it is scanned again with that
|
|
376
|
+
// opener read as text — otherwise every Stop would append another block inside it (0.12 review, round 2).
|
|
377
|
+
function findBlocks(text, ignore = new Set()) {
|
|
378
|
+
const blocks = []; let fence = null, open = null, at = 0, idx = -1;
|
|
379
|
+
for (const line of text.split('\n')) {
|
|
380
|
+
idx++; const start = at, end = at + line.length + 1; at = end;
|
|
381
|
+
const l = line.replace(/\r$/, '');
|
|
382
|
+
if (fence) {
|
|
383
|
+
const c = l.match(/^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/);
|
|
384
|
+
if (c && c[1][0] === fence.ch && c[1].length >= fence.len) fence = null;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const o = l.match(/^[ \t]{0,3}(`{3,}|~{3,})(.*)$/);
|
|
388
|
+
if (o && !ignore.has(idx) && !(o[1][0] === '`' && o[2].includes('`'))) { fence = { ch: o[1][0], len: o[1].length, idx }; continue; }
|
|
389
|
+
if (START_LINE.test(line)) { if (!open) open = { start, lineEnd: Math.min(end, text.length) }; continue; }
|
|
390
|
+
const m = line.match(END_LINE);
|
|
391
|
+
if (m && open) { blocks.push({ start: open.start, end: Math.min(end, text.length), keys: m[1] }); open = null; }
|
|
392
|
+
}
|
|
393
|
+
if (fence) return findBlocks(text, new Set([...ignore, fence.idx])); // each pass ignores one more opener: it ends
|
|
394
|
+
return { blocks, broken: open };
|
|
395
|
+
}
|
|
396
|
+
export function writeLessonsBlock(file, body, kind) {
|
|
397
|
+
let real = file;
|
|
398
|
+
try { real = fs.realpathSync(file); } catch (e) { let link = false; try { link = fs.lstatSync(file).isSymbolicLink(); } catch (e2) { } if (link) return { file, changed: false, reason: 'a link to nothing' }; }
|
|
399
|
+
const exists = fs.existsSync(real);
|
|
400
|
+
// Read-only by its mode bits (root passes an access() check on a 0444 file) or by access(): someone froze it; leave it.
|
|
401
|
+
if (exists) { let ok = (fs.statSync(real).mode & 0o222) !== 0; try { fs.accessSync(real, fs.constants.W_OK); } catch (e) { ok = false; } if (!ok) return { file, changed: false, reason: 'not writable' }; }
|
|
402
|
+
const raw = exists ? fs.readFileSync(real, 'utf8') : '';
|
|
403
|
+
const bom = raw.charCodeAt(0) === 0xfeff ? '' : '';
|
|
404
|
+
const text = bom ? raw.slice(1) : raw;
|
|
405
|
+
const eol = /\r\n/.test(text) ? '\r\n' : '\n';
|
|
406
|
+
const blk = body ? body.replace(/\r?\n/g, eol) : '';
|
|
407
|
+
const { blocks, broken } = findBlocks(text);
|
|
408
|
+
let out;
|
|
409
|
+
if (blocks.length) {
|
|
410
|
+
out = ''; let pos = 0;
|
|
411
|
+
blocks.forEach((b, i) => { out += text.slice(pos, b.start) + (i === 0 ? blk : ''); pos = b.end; });
|
|
412
|
+
let rest = text.slice(pos);
|
|
413
|
+
// Removed: take back the blank line that was put beside it, so the file reads as it did before the block.
|
|
414
|
+
if (!blk && rest.startsWith(eol) && (out === '' || out.endsWith(eol + eol))) rest = rest.slice(eol.length);
|
|
415
|
+
else if (!blk && rest === '' && out.endsWith(eol + eol)) out = out.slice(0, -eol.length);
|
|
416
|
+
out += rest;
|
|
417
|
+
} else if (broken) out = text.slice(0, broken.start) + blk + text.slice(broken.lineEnd);
|
|
418
|
+
else if (!blk) return { file, changed: false };
|
|
419
|
+
else if (kind === 'auto-memory') {
|
|
420
|
+
const m = /^# [^\r\n]*\r?\n(?:\r?\n)?/.exec(text); const at = m ? m[0].length : 0;
|
|
421
|
+
out = text.slice(0, at) + blk + (text.slice(at) ? eol : '') + text.slice(at);
|
|
422
|
+
} else out = text + (text && !text.endsWith('\n') ? eol : '') + (text.trim() ? eol : '') + blk;
|
|
423
|
+
out = bom + out;
|
|
424
|
+
if (out === raw) return { file, changed: false };
|
|
425
|
+
if (!exists && !out) return { file, changed: false };
|
|
426
|
+
fs.mkdirSync(path.dirname(real), { recursive: true });
|
|
427
|
+
const mode = exists ? fs.statSync(real).mode & 0o777 : 0o644;
|
|
428
|
+
const tmp = `${real}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.glassbox~`;
|
|
429
|
+
try { fs.writeFileSync(tmp, out, { flag: 'wx', mode }); fs.chmodSync(tmp, mode); fs.renameSync(tmp, real); }
|
|
430
|
+
catch (e) { try { fs.unlinkSync(tmp); } catch (e2) { } throw e; }
|
|
431
|
+
return { file, changed: true };
|
|
432
|
+
}
|
|
433
|
+
// The Stop hook's part: what this session started with (first Stop only), then the block for the next one.
|
|
434
|
+
export function syncLessonsBlock(cwd, opts = {}) {
|
|
435
|
+
if (!memoryPath(cwd)) return null;
|
|
436
|
+
const t = lessonsTarget(cwd, opts);
|
|
437
|
+
const led = loadLedger(cwd);
|
|
438
|
+
const blk = lessonsBlock(led, opts);
|
|
439
|
+
return Object.assign(writeLessonsBlock(t.file, blk ? blk.text : null, t.kind), { kind: t.kind, keys: blk ? blk.keys : [] });
|
|
440
|
+
}
|
|
441
|
+
export function toldFromBlock(cwd, sessionId, opts = {}) {
|
|
442
|
+
if (!sessionId || !memoryPath(cwd)) return false;
|
|
443
|
+
const led = loadLedger(cwd);
|
|
444
|
+
if (led.sessions.some((x) => x.id === sessionId) || led.pending[sessionId]) return false;
|
|
445
|
+
const t = lessonsTarget(cwd, opts);
|
|
446
|
+
const keys = fs.existsSync(t.file) ? blockKeys(fs.readFileSync(t.file, 'utf8')) : [];
|
|
447
|
+
return noteTold(cwd, sessionId, keys);
|
|
448
|
+
}
|
|
449
|
+
|
|
307
450
|
// ---------------------------------------------------------------------------------------------
|
|
308
451
|
// glassbox memory: the note the next session will get, and the lessons behind it.
|
|
309
452
|
// ---------------------------------------------------------------------------------------------
|
|
310
453
|
export function memoryReport(opts = {}) {
|
|
311
454
|
const project = path.resolve(opts.project || process.cwd());
|
|
312
455
|
const led = loadLedger(project), o = Object.assign({}, MEMORY_DEFAULTS, opts);
|
|
456
|
+
const target = lessonsTarget(project, opts); const blk = lessonsBlock(led, o);
|
|
313
457
|
const lessons = lessonStats(led, o);
|
|
314
458
|
const note = memoryNote(project, Object.assign({}, o, { ledger: led }));
|
|
315
459
|
const told = lessons.reduce((s, l) => s + l.told, 0), recurred = lessons.reduce((s, l) => s + l.recurred, 0);
|
|
316
460
|
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
461
|
sessions: led.sessions.length, window: o.window, retireAfter: o.retireAfter,
|
|
318
462
|
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
|
|
463
|
+
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,
|
|
464
|
+
block: { file: target.file, kind: target.kind, text: blk ? blk.text : null } };
|
|
320
465
|
}
|
|
321
466
|
export function memoryText(rep) {
|
|
322
467
|
const s = rep.summary;
|
|
@@ -327,6 +472,11 @@ export function memoryText(rep) {
|
|
|
327
472
|
L.push('');
|
|
328
473
|
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
474
|
}
|
|
475
|
+
if (rep.block) {
|
|
476
|
+
L.push('', rep.block.text ? ` The agent's own memory carries these lessons, in ${rep.block.file} (${rep.block.kind === 'auto-memory' ? 'auto memory' : 'CLAUDE.local.md'}):` : ` No lessons to carry: the block in ${rep.block.file} is removed at the next Stop.`);
|
|
477
|
+
if (rep.block.text) { L.push(''); for (const line of rep.block.text.replace(/\n$/, '').split('\n')) L.push(' │ ' + line); }
|
|
478
|
+
L.push('', ' (With --memory glassbox the next session gets this note from the SessionStart hook instead:)');
|
|
479
|
+
}
|
|
330
480
|
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
481
|
if (rep.note) { L.push(''); for (const line of rep.note.replace(/\n$/, '').split('\n')) L.push(' │ ' + line); }
|
|
332
482
|
L.push('', ' glassbox memory forget <key|rule> drops a lesson · glassbox memory clear deletes the memory · glassbox recall <words> searches it');
|
|
@@ -371,7 +521,8 @@ export function recall(opts = {}) {
|
|
|
371
521
|
for (const s of list) {
|
|
372
522
|
const files = loadSessionFiles(s);
|
|
373
523
|
const words = terms.flatMap((t) => t.split(/\s+/)).filter(rawSafe);
|
|
374
|
-
|
|
524
|
+
// The raw-text shortcut needs the text; a streamed (big) transcript has records instead and is parsed (0.12).
|
|
525
|
+
if (words.length && files.every((f) => typeof f.text === 'string')) { const raw = files.map((f) => f.text).join('\n').toLowerCase(); if (!words.every((w) => raw.includes(w))) continue; }
|
|
375
526
|
const d = digestOf(core.parseTrace(files), { cwd: project, id: s.id }); parsed++;
|
|
376
527
|
const prev = byId.get(s.id);
|
|
377
528
|
byId.set(s.id, Object.assign(d, { hit: prev ? prev.hit : [], cost: prev ? prev.cost : null, source: prev ? 'both' : 'transcript' }));
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// glassbox plan — what a Pro/Max plan is buying, from the transcripts on this machine.
|
|
2
|
+
//
|
|
3
|
+
// Glassbox prices every request at API list price. A plan subscriber doesn't pay that; what they want to know is
|
|
4
|
+
// what the plan carried and where the heavy stretches were. `plan` reads the plan tier from ~/.claude.json (two
|
|
5
|
+
// fields, nothing else), buckets every request by its own timestamp into billing months, the last 7 days and
|
|
6
|
+
// rebuilt 5-hour windows, and finds limit hits. It cannot see the plan's limits: Claude Code gives the % used only
|
|
7
|
+
// to the status line, never to a transcript, and the limits count claude.ai and other machines too (DESIGN.md §22).
|
|
8
|
+
// Nothing leaves the machine.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { findSessions, loadSessionFiles, sessionTitle, claudeHome } from './cli.mjs';
|
|
13
|
+
import { readTextFile } from './textfile.mjs';
|
|
14
|
+
import { readSnapshots } from './statusline.mjs';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const core = require('./trace-core.js');
|
|
17
|
+
|
|
18
|
+
const H = 3600e3, D = 24 * H, WINDOW = 5 * H;
|
|
19
|
+
|
|
20
|
+
// Rate-limit tiers as ~/.claude.json names them → plan and its monthly price in USD (claude.com/pricing).
|
|
21
|
+
export const PLANS = {
|
|
22
|
+
pro: { label: 'Pro', price: 20 },
|
|
23
|
+
max_5x: { label: 'Max 5x', price: 100 },
|
|
24
|
+
max_20x: { label: 'Max 20x', price: 200 },
|
|
25
|
+
};
|
|
26
|
+
export const tierPlan = (tier) => { const m = String(tier || '').toLowerCase().match(/(max_20x|max_5x|pro)\b/); return m ? m[1] : null; };
|
|
27
|
+
|
|
28
|
+
// Where Claude Code keeps .claude.json: inside the home when the home is a CLAUDE_CONFIG_DIR, else next to it.
|
|
29
|
+
// The first one that has a signed-in account wins (an old copy without one can sit inside ~/.claude).
|
|
30
|
+
export function readPlan(home) {
|
|
31
|
+
home = home || claudeHome();
|
|
32
|
+
let first = null;
|
|
33
|
+
for (const f of [path.join(home, '.claude.json'), path.join(path.dirname(home), '.claude.json')]) {
|
|
34
|
+
if (!fs.existsSync(f)) continue;
|
|
35
|
+
first = first || f;
|
|
36
|
+
let acct; try { acct = (JSON.parse(readTextFile(f)) || {}).oauthAccount; } catch (e) { continue; }
|
|
37
|
+
if (!acct) continue;
|
|
38
|
+
const tier = typeof acct.organizationRateLimitTier === 'string' ? acct.organizationRateLimitTier : typeof acct.userRateLimitTier === 'string' ? acct.userRateLimitTier : null;
|
|
39
|
+
return { file: f, plan: tierPlan(tier), tier, since: acct.subscriptionCreatedAt || null };
|
|
40
|
+
}
|
|
41
|
+
return { file: first, plan: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Billing months run from the subscription's day and time of day (UTC); a 31st falls on a short month's last day.
|
|
45
|
+
const monthStart = (y, m, day, tod) => { const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate(); return Date.UTC(y, m, Math.min(day, last)) + tod; };
|
|
46
|
+
export function billingPeriods(anchor, now, count = 2) {
|
|
47
|
+
const a = new Date(anchor), day = a.getUTCDate(), tod = anchor - Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), a.getUTCDate());
|
|
48
|
+
const n = new Date(now); let y = n.getUTCFullYear(), m = n.getUTCMonth();
|
|
49
|
+
if (monthStart(y, m, day, tod) > now) m--;
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < count; i++) { const from = monthStart(y, m - i, day, tod), to = monthStart(y, m - i + 1, day, tod); if (to <= anchor) break; out.push({ from: Math.max(from, anchor), to }); }
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A 5-hour window opens at the first request after the previous one closed (the way ccusage `blocks` rebuilds them).
|
|
56
|
+
// Usage from claude.ai or another machine can open a window Glassbox never sees, so these are estimates.
|
|
57
|
+
export function rebuildWindows(reqs) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const r of reqs) { const w = out[out.length - 1]; if (!w || r.t >= w.start + WINDOW) out.push({ start: r.t, end: r.t + WINDOW, reqs: [r] }); else w.reqs.push(r); }
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A limit hit is the synthetic assistant record Claude Code writes in place of a reply.
|
|
64
|
+
const LIMIT_TEXT = /usage limit|hit your limit|limit reached|limit will reset|resets? (?:at|in)\b/i;
|
|
65
|
+
export function limitKind(r) {
|
|
66
|
+
if (!r.isApiError) return null;
|
|
67
|
+
const text = r.blocks.filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
68
|
+
if (LIMIT_TEXT.test(text) || r.apiError === 'billing_error') return 'plan';
|
|
69
|
+
if (r.apiError === 'rate_limit') return 'rate';
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const family = (m) => String(m || 'unknown').replace(/-\d{8}$/, '');
|
|
74
|
+
function bucket(reqs, from, to) {
|
|
75
|
+
const list = reqs.filter((r) => r.t >= from && r.t < to);
|
|
76
|
+
const byModel = {}, bySession = {};
|
|
77
|
+
for (const r of list) { byModel[family(r.model)] = (byModel[family(r.model)] || 0) + r.cost; bySession[r.session] = (bySession[r.session] || 0) + r.cost; }
|
|
78
|
+
const list$ = list.reduce((a, r) => a + r.cost, 0);
|
|
79
|
+
return { list: list$, requests: list.length, byModel, bySession };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function plan(opts = {}) {
|
|
83
|
+
const home = opts.home || claudeHome();
|
|
84
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
85
|
+
const since = opts.since != null ? opts.since : 7 * D;
|
|
86
|
+
const found = readPlan(home);
|
|
87
|
+
const key = opts.plan !== undefined ? (opts.plan === 'none' ? null : tierPlan(String(opts.plan).replace(/-/g, '_'))) : found.plan;
|
|
88
|
+
if (opts.plan !== undefined && opts.plan !== 'none' && !key) throw new Error(`--plan must be pro, max-5x, max-20x or none (got "${opts.plan}")`);
|
|
89
|
+
const info = key ? PLANS[key] : null;
|
|
90
|
+
const price = opts.planPrice != null ? opts.planPrice : info ? info.price : null;
|
|
91
|
+
|
|
92
|
+
// The billing anchor: --bill-day (00:00 UTC on that day), else the subscription's own start.
|
|
93
|
+
// A January a year back carries any day 1–31, and billingPeriods only reads its day and time of day.
|
|
94
|
+
let anchor = null;
|
|
95
|
+
if (opts.billDay != null) {
|
|
96
|
+
if (!Number.isInteger(opts.billDay) || opts.billDay < 1 || opts.billDay > 31) throw new Error(`--bill-day must be a day of the month, 1–31 (got "${opts.billDay}")`);
|
|
97
|
+
anchor = Date.UTC(new Date(now).getUTCFullYear() - 1, 0, opts.billDay);
|
|
98
|
+
} else if (found.since && key) anchor = Date.parse(found.since);
|
|
99
|
+
const periods = info && anchor != null && Number.isFinite(anchor) ? billingPeriods(anchor, now, 2) : [];
|
|
100
|
+
const from = Math.min(now - since, now - 7 * D, ...periods.map((p) => p.from));
|
|
101
|
+
|
|
102
|
+
// Every request, once: a resumed session repeats earlier records under the same requestId.
|
|
103
|
+
const seen = new Set(), reqs = [], hits = [], files = {}; const unpriced = new Set(); let unpricedN = 0, sessions = 0;
|
|
104
|
+
for (const s of findSessions({ home }).filter((x) => x.mtime >= from)) {
|
|
105
|
+
let trace; try { trace = core.parseTrace(loadSessionFiles(s)); } catch (e) { continue; }
|
|
106
|
+
sessions++; files[s.id] = s.file;
|
|
107
|
+
for (const r of trace.requests) {
|
|
108
|
+
if (r.start == null || r.start < from || seen.has(r.id)) continue;
|
|
109
|
+
seen.add(r.id);
|
|
110
|
+
const kind = limitKind(r);
|
|
111
|
+
if (kind) { if (r.start >= now - since) hits.push({ t: r.start, kind, session: s.id, code: r.apiError || null }); continue; }
|
|
112
|
+
if (r.isApiError || r.model === '<synthetic>') continue; // written by Claude Code, not an API reply
|
|
113
|
+
const cost = core.requestCost(r.usage, core.rateFor(r.model, opts.rates));
|
|
114
|
+
if (cost == null) { unpricedN++; if (r.model) unpriced.add(r.model); continue; }
|
|
115
|
+
reqs.push({ t: r.start, cost, model: r.model, session: s.id });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
reqs.sort((a, b) => a.t - b.t); hits.sort((a, b) => a.t - b.t);
|
|
119
|
+
|
|
120
|
+
const title = (id) => { if (opts.redact) return null; const t = sessionTitle(files[id]); return /^\((no prompt found|unreadable)\)$/.test(t) ? null : t; };
|
|
121
|
+
const topOf = (bySession, total) => { const e = Object.entries(bySession).sort((a, b) => b[1] - a[1])[0]; return e ? { session: e[0], list: e[1], share: total ? e[1] / total : 0, title: title(e[0]) } : null; };
|
|
122
|
+
const models = (byModel, total) => Object.entries(byModel).sort((a, b) => b[1] - a[1]).map(([model, list]) => ({ model, list, share: total ? list / total : 0 }));
|
|
123
|
+
|
|
124
|
+
// Rebuilt from everything read (so the first window in range isn't cut at its edge), kept if it ends in range.
|
|
125
|
+
const windows = key ? rebuildWindows(reqs).filter((w) => w.end > now - since).map((w) => {
|
|
126
|
+
const b = bucket(w.reqs, w.start, w.end);
|
|
127
|
+
return { start: w.start, end: w.end, open: now < w.end, list: b.list, requests: b.requests, top: topOf(b.bySession, b.list) };
|
|
128
|
+
}) : [];
|
|
129
|
+
const week = bucket(reqs, now - 7 * D, now + 1);
|
|
130
|
+
const rateCard = core.rateCardCheck(reqs.map((r) => r.model).concat([...unpriced]), opts.rates || undefined, { latest: reqs.length ? reqs[reqs.length - 1].t : null, custom: !!(opts.rates && opts.rates._custom) });
|
|
131
|
+
const live = opts.live ? liveFrom(opts.snapshots || readSnapshots(home), reqs, { now, since, title, top: opts.top || 5 }) : null;
|
|
132
|
+
return {
|
|
133
|
+
glassbox: core.VERSION, schema: 1, kind: 'plan', generated: now, redacted: !!opts.redact,
|
|
134
|
+
scope: 'Claude Code transcripts on this machine; claude.ai, the apps and other machines are not included',
|
|
135
|
+
plan: { key, label: info ? info.label : null, price, priceFrom: opts.planPrice != null ? 'flag' : info ? 'default' : null, tier: found.tier || null, file: found.file, override: opts.plan !== undefined },
|
|
136
|
+
sessions, requests: reqs.length, since,
|
|
137
|
+
periods: periods.map((p) => { const b = bucket(reqs, p.from, p.to); const elapsed = Math.min(now, p.to) - p.from;
|
|
138
|
+
return { from: p.from, to: p.to, current: now < p.to, days: Math.round((p.to - p.from) / D), daysElapsed: Math.max(1, Math.ceil(elapsed / D)), list: b.list, requests: b.requests, multiple: price ? b.list / price : null, models: models(b.byModel, b.list) }; }),
|
|
139
|
+
last7d: { from: now - 7 * D, list: week.list, requests: week.requests, models: models(week.byModel, week.list) },
|
|
140
|
+
windows: { count: windows.length, current: windows.find((w) => w.open) || null, top: windows.slice().sort((a, b) => b.list - a.list).slice(0, opts.top || 5) },
|
|
141
|
+
limitHits: hits,
|
|
142
|
+
unpriced: { requests: unpricedN, models: [...unpriced] },
|
|
143
|
+
rateCard, live, pricing: core.pricingNote(reqs.map((r) => r.model), opts.rates || undefined),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// --live: the status line's snapshots (src/statusline.mjs) against the local requests
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// A window is known exactly from its resets_at: the 5-hour one started 5 h before it, the week 7 days before.
|
|
151
|
+
// resets_at is whole seconds and can wobble by one between readings, so readings are grouped to the minute.
|
|
152
|
+
// A session's share is what the % did between its own first and last reading in each window: other sessions,
|
|
153
|
+
// claude.ai and other machines in that time count too, so it is "while it ran", not "because of it".
|
|
154
|
+
export function liveFrom(snaps, reqs, opts) {
|
|
155
|
+
const { now, since } = opts;
|
|
156
|
+
const recent = snaps.filter((s) => s.t >= now - Math.max(since, 7 * D));
|
|
157
|
+
const groups = (kind, len) => {
|
|
158
|
+
const by = new Map();
|
|
159
|
+
for (const s of recent) { const w = s[kind]; if (!w) continue; const k = Math.round(w.resets / 60e3); const g = by.get(k) || { start: w.resets - len, end: w.resets, peak: 0, readings: 0, first: s.t, last: s.t }; g.peak = Math.max(g.peak, w.pct); g.readings++; g.last = s.t; by.set(k, g); }
|
|
160
|
+
return [...by.values()].sort((a, b) => b.start - a.start).map((g) => { const b = bucket(reqs, g.start, Math.min(g.end, now)); return Object.assign(g, { open: now < g.end, list: b.list, requests: b.requests }); });
|
|
161
|
+
};
|
|
162
|
+
const deltas = (list, kind) => { const by = new Map(); for (const s of list) { const w = s[kind]; if (!w) continue; const k = Math.round(w.resets / 60e3); const g = by.get(k) || { lo: w.pct, hi: w.pct }; g.hi = w.pct; by.set(k, g); } let d = 0; for (const g of by.values()) d += Math.max(0, g.hi - g.lo); return d; };
|
|
163
|
+
const bySession = new Map(); for (const s of recent) { if (!s.session) continue; if (!bySession.has(s.session)) bySession.set(s.session, []); bySession.get(s.session).push(s); }
|
|
164
|
+
const sessions = [...bySession.entries()].filter(([, l]) => l.length >= 2).map(([id, l]) => {
|
|
165
|
+
const from = l[0].t, to = l[l.length - 1].t, b = bucket(reqs.filter((r) => r.session === id), from, to + 1);
|
|
166
|
+
return { session: id, title: opts.title(id), readings: l.length, from, to, week: deltas(l, 'week'), five: deltas(l, 'five'), list: b.list, requests: b.requests };
|
|
167
|
+
}).sort((a, b) => b.week - a.week || b.five - a.five).slice(0, opts.top);
|
|
168
|
+
const latest = recent[recent.length - 1] || null;
|
|
169
|
+
const fresh = (w) => w && w.resets > now ? w : null;
|
|
170
|
+
return {
|
|
171
|
+
snapshots: recent.length, newest: latest ? latest.t : null,
|
|
172
|
+
now: latest ? { t: latest.t, five: fresh(latest.five), week: fresh(latest.week) } : null,
|
|
173
|
+
fiveHour: groups('five', 5 * H).slice(0, opts.top), weeks: groups('week', 7 * D).slice(0, 2), sessions,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Rendering
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
181
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
182
|
+
const day = (t) => { const d = new Date(t); return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`; };
|
|
183
|
+
const hm = (t) => { const d = new Date(t); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; };
|
|
184
|
+
const usd = (v) => '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
185
|
+
const pct = (v) => v > 0 && v < 0.005 ? '<1%' : Math.round(v * 100) + '%';
|
|
186
|
+
const mult = (v) => v.toFixed(1) + '×';
|
|
187
|
+
const short = (m) => m.replace(/^claude-/, '').replace(/-(\d+)-(\d+)$/, ' $1.$2').replace(/-(\d+)$/, ' $1').replace(/^./, (c) => c.toUpperCase());
|
|
188
|
+
const modelLine = (ms) => ms.slice(0, 3).map((m) => `${short(m.model)} ${pct(m.share)}`).join(' · ');
|
|
189
|
+
const sinceText = (ms) => ms % D === 0 ? `${ms / D}d` : ms % H === 0 ? `${ms / H}h` : `${Math.round(ms / 60e3)}m`;
|
|
190
|
+
const NOTE = ['List price is what the same tokens would cost on the API. It isn\'t money saved, and it isn\'t your share',
|
|
191
|
+
'of the plan\'s limits: Claude Code shows those only to the status line, and they count claude.ai and other machines too.'];
|
|
192
|
+
|
|
193
|
+
function headline(rep) {
|
|
194
|
+
const p = rep.plan;
|
|
195
|
+
if (!p.key) return `Glassbox plan · no Claude plan found${p.file ? ` in ${p.file}` : ''} (API key, Bedrock or Vertex?) · list price is what you pay`;
|
|
196
|
+
return `Glassbox plan · ${p.label}${p.override ? '' : ' (from .claude.json)'} · ${p.price != null ? usd(p.price).replace(/\.00$/, '') + '/month' + (p.priceFrom === 'default' ? ' (USD list; --plan-price for yours)' : '') : 'no price'} · Claude Code on this machine only`;
|
|
197
|
+
}
|
|
198
|
+
const periodLabel = (p) => p.current ? `billing month from ${day(p.from)}` : `${day(p.from)} – ${day(p.to)}`;
|
|
199
|
+
const windowRow = (w, redact) => `${day(w.start)} ${hm(w.start)}–${hm(w.end)} ${usd(w.list).padStart(10)} ${String(w.requests).padStart(5)} req${w.top ? ` top ${w.top.session.slice(0, 8)} ${pct(w.top.share)}${!redact && w.top.title ? ` "${w.top.title.slice(0, 50)}"` : ''}` : ''}`;
|
|
200
|
+
|
|
201
|
+
export function planText(rep) {
|
|
202
|
+
const L = [headline(rep), ''];
|
|
203
|
+
for (const p of rep.periods) L.push(` ${periodLabel(p).padEnd(32)} ${usd(p.list).padStart(11)} list${p.multiple != null ? ` vs ${usd(rep.plan.price).replace(/\.00$/, '')} plan ${mult(p.multiple)}` : ''}${p.current ? ` (day ${p.daysElapsed} of ${p.days})` : ''}`);
|
|
204
|
+
L.push(` ${'last 7 days'.padEnd(32)} ${usd(rep.last7d.list).padStart(11)} list ${modelLine(rep.last7d.models)}`);
|
|
205
|
+
if (rep.plan.key) {
|
|
206
|
+
L.push('', ` 5-hour windows, last ${sinceText(rep.since)}: ${rep.windows.count} (rebuilt from request times; usage elsewhere can shift them)`);
|
|
207
|
+
if (rep.windows.current) L.push(` now ${windowRow(rep.windows.current, rep.redacted)}`);
|
|
208
|
+
for (const w of rep.windows.top) if (!rep.windows.current || w.start !== rep.windows.current.start) L.push(` ${windowRow(w, rep.redacted)}`);
|
|
209
|
+
L.push('', rep.limitHits.length ? ` limit hits: ${rep.limitHits.length}` : ` limit hits: none in the last ${sinceText(rep.since)}`);
|
|
210
|
+
for (const h of rep.limitHits.slice(-10)) L.push(` ${day(h.t)} ${hm(h.t)} ${h.kind === 'plan' ? 'plan limit' : 'rate limited'} ${h.session.slice(0, 8)}`);
|
|
211
|
+
}
|
|
212
|
+
if (rep.live) L.push('', ...liveLines(rep.live, rep.redacted, rep.generated).map((s) => ' ' + s));
|
|
213
|
+
if (rep.unpriced.requests) L.push('', ` ${rep.unpriced.requests} request${rep.unpriced.requests === 1 ? '' : 's'} not priced (no rate for ${rep.unpriced.models.join(', ') || 'an unnamed model'}; --rates FILE adds one)`);
|
|
214
|
+
if (rep.pricing) L.push('', ' ' + rep.pricing);
|
|
215
|
+
const rl = core.rateCardLine(rep.rateCard); if (rl) L.push('', ' ' + rl);
|
|
216
|
+
L.push('', ...(rep.live && rep.live.snapshots ? NOTE_LIVE : NOTE).map((s) => ' ' + s));
|
|
217
|
+
return L.join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const NOTE_LIVE = ['List price is what the same tokens would cost on the API, not money saved. The % readings are the plan\'s own,',
|
|
221
|
+
'from the status line; they count claude.ai and other machines too, which the local list price does not.'];
|
|
222
|
+
const ago = (ms) => ms < 60e3 ? 'just now' : ms < H ? `${Math.round(ms / 60e3)}m ago` : ms < D ? `${Math.round(ms / H)}h ago` : `${Math.round(ms / D)}d ago`;
|
|
223
|
+
const span = (a, b) => `${day(a)} ${hm(a)} – ${day(b)} ${hm(b)}`;
|
|
224
|
+
function liveLines(lv, redact, now) {
|
|
225
|
+
if (!lv.snapshots) return ['From the status line: no readings yet. Run `glassbox statusline install`, then use Claude Code for a while.'];
|
|
226
|
+
const L = [`From the status line: ${lv.snapshots} reading${lv.snapshots === 1 ? '' : 's'}, newest ${ago(now - lv.newest)}`];
|
|
227
|
+
const n = lv.now;
|
|
228
|
+
L.push(` latest ${n.five ? `5h ${pct(n.five.pct / 100)} (resets ${hm(n.five.resets)})` : '5h — (reset since)'} · ${n.week ? `week ${pct(n.week.pct / 100)} (resets ${day(n.week.resets)} ${hm(n.week.resets)})` : 'week — (reset since)'}`);
|
|
229
|
+
if (lv.fiveHour.length) { L.push(' 5-hour windows (exact)'); for (const w of lv.fiveHour) L.push(` ${day(w.start)} ${hm(w.start)}–${hm(w.end)} peak ${pct(w.peak / 100).padStart(4)} ${usd(w.list).padStart(10)} local${w.open ? ' (open)' : ''}`); }
|
|
230
|
+
if (lv.weeks.length) { L.push(' weeks'); for (const w of lv.weeks) L.push(` ${span(w.start, w.end)} peak ${pct(w.peak / 100).padStart(4)} ${usd(w.list).padStart(10)} local${w.open ? ' (open)' : ''}`); }
|
|
231
|
+
if (lv.sessions.length) {
|
|
232
|
+
L.push(' sessions, by how much the week moved while each ran (other usage in that time counts too)');
|
|
233
|
+
for (const s of lv.sessions) L.push(` ${s.session.slice(0, 8)} week +${pct(s.week / 100)} 5h +${pct(s.five / 100)} ${usd(s.list).padStart(9)} local${!redact && s.title ? ` "${s.title.slice(0, 50)}"` : ''}`);
|
|
234
|
+
}
|
|
235
|
+
return L;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function planMarkdown(rep) {
|
|
239
|
+
const L = [`## ${headline(rep)}`, '', '| Period | List price | Plan | Multiple | Top models |', '|---|---:|---:|---:|---|'];
|
|
240
|
+
for (const p of rep.periods) L.push(`| ${periodLabel(p)}${p.current ? ` (day ${p.daysElapsed} of ${p.days})` : ''} | ${usd(p.list)} | ${rep.plan.price != null ? usd(rep.plan.price) : '—'} | ${p.multiple != null ? mult(p.multiple) : '—'} | ${modelLine(p.models)} |`);
|
|
241
|
+
L.push(`| last 7 days | ${usd(rep.last7d.list)} | | | ${modelLine(rep.last7d.models)} |`);
|
|
242
|
+
if (rep.plan.key) {
|
|
243
|
+
L.push('', `### 5-hour windows, last ${sinceText(rep.since)}: ${rep.windows.count}`, '', '*Rebuilt from request times; usage elsewhere can shift them.*', '', '| Window | List price | Requests | Top session |', '|---|---:|---:|---|');
|
|
244
|
+
const rows = rep.windows.current ? [rep.windows.current, ...rep.windows.top.filter((w) => w.start !== rep.windows.current.start)] : rep.windows.top;
|
|
245
|
+
for (const w of rows) L.push(`| ${w.open ? 'now: ' : ''}${day(w.start)} ${hm(w.start)}–${hm(w.end)} | ${usd(w.list)} | ${w.requests} | ${w.top ? `\`${w.top.session.slice(0, 8)}\` ${pct(w.top.share)}${!rep.redacted && w.top.title ? ` ${w.top.title.slice(0, 50).replace(/\|/g, '\\|')}` : ''}` : ''} |`);
|
|
246
|
+
L.push('', `**Limit hits:** ${rep.limitHits.length ? rep.limitHits.map((h) => `${day(h.t)} ${hm(h.t)} (${h.kind === 'plan' ? 'plan limit' : 'rate limited'}, \`${h.session.slice(0, 8)}\`)`).join(', ') : `none in the last ${sinceText(rep.since)}`}`);
|
|
247
|
+
}
|
|
248
|
+
if (rep.live) L.push('', '### Live', '', '```', ...liveLines(rep.live, rep.redacted, rep.generated), '```');
|
|
249
|
+
if (rep.unpriced.requests) L.push('', `${rep.unpriced.requests} request(s) not priced: no rate for ${rep.unpriced.models.join(', ') || 'an unnamed model'}.`);
|
|
250
|
+
const rl = core.rateCardLine(rep.rateCard); if (rl) L.push('', `> ${rl}`);
|
|
251
|
+
L.push('', (rep.live && rep.live.snapshots ? NOTE_LIVE : NOTE).join(' '), '');
|
|
252
|
+
return L.join('\n');
|
|
253
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// glassbox statusline — the plan's real limits in Claude Code's status bar, and a log of them for `plan --live`.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code gives a status-line command what it never writes to a transcript: `rate_limits.five_hour` and
|
|
4
|
+
// `rate_limits.seven_day`, each { used_percentage 0–100, resets_at epoch seconds }, for subscribers, after the first
|
|
5
|
+
// API response. This prints them in one line and appends a snapshot to <claude home>/glassbox/limits.jsonl when
|
|
6
|
+
// they change, so `plan --live` can show exact windows and what each session took (DESIGN.md §23). It runs on
|
|
7
|
+
// every status refresh, so it parses no transcript and never fails loudly: whatever goes wrong, it prints a line
|
|
8
|
+
// and exits 0. Nothing leaves the machine.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { claudeHome, settingsPath } from './cli.mjs';
|
|
13
|
+
import { readTextFile } from './textfile.mjs';
|
|
14
|
+
|
|
15
|
+
const M = 60e3, H = 60 * M;
|
|
16
|
+
export const LIMITS_CAP = 4 * 1024 * 1024; // then limits.jsonl becomes limits.1.jsonl (one older file kept)
|
|
17
|
+
const REPEAT_MS = 10 * M; // an unchanged reading is logged again after this long
|
|
18
|
+
export const limitsFile = (home) => path.join(home || claudeHome(), 'glassbox', 'limits.jsonl');
|
|
19
|
+
export const previousFile = (home) => path.join(home || claudeHome(), 'glassbox', 'statusline-previous.json');
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Snapshots
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const toMs = (v) => typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : typeof v === 'string' ? Date.parse(v) : NaN;
|
|
25
|
+
const win = (w) => w && typeof w.used_percentage === 'number' && Number.isFinite(toMs(w.resets_at)) ? { pct: w.used_percentage, resets: toMs(w.resets_at) } : null;
|
|
26
|
+
|
|
27
|
+
export function snapshotFrom(input, now = Date.now()) {
|
|
28
|
+
const rl = input && input.rate_limits;
|
|
29
|
+
if (!rl) return null;
|
|
30
|
+
const five = win(rl.five_hour), week = win(rl.seven_day);
|
|
31
|
+
if (!five && !week) return null;
|
|
32
|
+
return { t: now, session: input.session_id || null, model: (input.model && input.model.id) || null, five, week };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const same = (a, b) => (!a && !b) || (a && b && a.pct === b.pct && a.resets === b.resets);
|
|
36
|
+
// The last few KB of the log, parsed newest first (a partial first line is skipped).
|
|
37
|
+
function tail(file, bytes = 65536) {
|
|
38
|
+
let fd; try { fd = fs.openSync(file, 'r'); const size = fs.fstatSync(fd).size; const n = Math.min(size, bytes); const buf = Buffer.alloc(n); fs.readSync(fd, buf, 0, n, size - n);
|
|
39
|
+
const lines = buf.toString('utf8').split('\n'); if (n < size) lines.shift();
|
|
40
|
+
const out = []; for (let i = lines.length - 1; i >= 0; i--) { if (!lines[i].trim()) continue; try { out.push(JSON.parse(lines[i])); } catch (e) { } } return out;
|
|
41
|
+
} catch (e) { return []; } finally { if (fd != null) fs.closeSync(fd); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Append when this session's reading changed, or REPEAT_MS after its last line. Returns whether it wrote.
|
|
45
|
+
export function logSnapshot(home, snap) {
|
|
46
|
+
if (!snap) return false;
|
|
47
|
+
const file = limitsFile(home);
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
49
|
+
const last = tail(file).find((s) => s.session === snap.session);
|
|
50
|
+
if (last && same(last.five, snap.five) && same(last.week, snap.week) && snap.t - last.t < REPEAT_MS) return false;
|
|
51
|
+
try { if (fs.statSync(file).size > LIMITS_CAP) fs.renameSync(file, file.replace(/\.jsonl$/, '.1.jsonl')); } catch (e) { }
|
|
52
|
+
fs.appendFileSync(file, JSON.stringify(snap) + '\n', { mode: 0o600 });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readSnapshots(home) {
|
|
57
|
+
const file = limitsFile(home), out = [];
|
|
58
|
+
for (const f of [file.replace(/\.jsonl$/, '.1.jsonl'), file]) {
|
|
59
|
+
if (!fs.existsSync(f)) continue;
|
|
60
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) { if (!line.trim()) continue; try { const s = JSON.parse(line); if (typeof s.t === 'number') out.push(s); } catch (e) { } }
|
|
61
|
+
}
|
|
62
|
+
return out.sort((a, b) => a.t - b.t);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// The line
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
69
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
70
|
+
const hm = (t) => { const d = new Date(t); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; };
|
|
71
|
+
const left = (ms) => { const m = Math.max(0, Math.round(ms / M)); return m >= 60 ? `${Math.floor(m / 60)}h${p2(m % 60)}m` : `${m}m`; };
|
|
72
|
+
const pctText = (v) => (Math.round(v * 10) / 10) + '%';
|
|
73
|
+
|
|
74
|
+
export function statusText(input, opts = {}) {
|
|
75
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
76
|
+
const color = opts.color !== false;
|
|
77
|
+
const paint = (pct, s) => !color ? s : `\x1b[${pct >= 80 ? 31 : pct >= 50 ? 33 : 32}m${s}\x1b[0m`;
|
|
78
|
+
const dim = (s) => color ? `\x1b[2m${s}\x1b[0m` : s;
|
|
79
|
+
const parts = [];
|
|
80
|
+
const model = input && input.model && (input.model.display_name || input.model.id);
|
|
81
|
+
const ctx = input && input.context_window && typeof input.context_window.used_percentage === 'number' ? input.context_window.used_percentage : null;
|
|
82
|
+
if (model) parts.push(model + (ctx != null ? dim(` · ctx ${Math.round(ctx)}%`) : ''));
|
|
83
|
+
if (input && input.rate_limits_available === false) parts.push(dim('no plan limits (API key, Bedrock or Vertex)'));
|
|
84
|
+
else {
|
|
85
|
+
const snap = snapshotFrom(input, now);
|
|
86
|
+
const five = snap && snap.five, week = snap && snap.week;
|
|
87
|
+
parts.push(five ? `5h ${paint(five.pct, pctText(five.pct))}${dim(' · ' + left(five.resets - now) + ' left')}` : dim('5h —'));
|
|
88
|
+
parts.push(week ? `week ${paint(week.pct, pctText(week.pct))}${dim(' · resets ' + (week.resets - now > 24 * H ? DAYS[new Date(week.resets).getDay()] + ' ' : '') + hm(week.resets))}` : dim('week —'));
|
|
89
|
+
}
|
|
90
|
+
return parts.join(dim(' │ '));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A status line the user had before `statusline install` keeps running, in front of ours. Process start can take
|
|
94
|
+
// seconds on a busy Windows machine, so when it runs out of time its last good line is shown instead of nothing.
|
|
95
|
+
function previousLine(home, raw, timeout = 2000) {
|
|
96
|
+
const f = previousFile(home);
|
|
97
|
+
if (!fs.existsSync(f)) return null;
|
|
98
|
+
let prev; try { prev = JSON.parse(readTextFile(f)).statusLine; } catch (e) { return null; }
|
|
99
|
+
if (!prev || prev.type !== 'command' || typeof prev.command !== 'string') return null;
|
|
100
|
+
const cache = f.replace(/\.json$/, '.last');
|
|
101
|
+
const r = spawnSync(prev.command, { shell: true, input: raw, encoding: 'utf8', timeout, windowsHide: true });
|
|
102
|
+
const line = String(r.stdout || '').split('\n')[0].trimEnd();
|
|
103
|
+
if (line && !r.error) { try { fs.writeFileSync(cache, line); } catch (e) { } return line; }
|
|
104
|
+
try { return fs.readFileSync(cache, 'utf8') || null; } catch (e) { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What `glassbox statusline` does on each refresh: log, then print.
|
|
108
|
+
export function statusline(input, opts = {}) {
|
|
109
|
+
const home = opts.home || claudeHome();
|
|
110
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
111
|
+
try { logSnapshot(home, snapshotFrom(input, now)); } catch (e) { } // a full disk must not blank the status bar
|
|
112
|
+
const ours = statusText(input, { now, color: opts.color });
|
|
113
|
+
let theirs = null; try { theirs = previousLine(home, opts.raw != null ? opts.raw : JSON.stringify(input || {}), opts.previousTimeout); } catch (e) { }
|
|
114
|
+
return theirs ? `${theirs} │ ${ours}` : ours;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// install / uninstall
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
export const STATUS_RE = /\bglassbox(?:-trace)?(?:\.mjs)?["']?\s+statusline\b/;
|
|
121
|
+
const ours = (sl) => sl && typeof sl.command === 'string' && STATUS_RE.test(sl.command);
|
|
122
|
+
|
|
123
|
+
export function installStatusLine(opts = {}) {
|
|
124
|
+
const home = opts.home || claudeHome();
|
|
125
|
+
const file = settingsPath(home);
|
|
126
|
+
let settings = {};
|
|
127
|
+
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'); }
|
|
128
|
+
let kept = null;
|
|
129
|
+
if (settings.statusLine && !ours(settings.statusLine)) {
|
|
130
|
+
const prev = previousFile(home); fs.mkdirSync(path.dirname(prev), { recursive: true, mode: 0o700 });
|
|
131
|
+
fs.writeFileSync(prev, JSON.stringify({ savedAt: new Date().toISOString(), statusLine: settings.statusLine }, null, 2) + '\n');
|
|
132
|
+
kept = settings.statusLine.command || '(not a command)';
|
|
133
|
+
}
|
|
134
|
+
const command = `${opts.command} statusline${opts.noColor ? ' --no-color' : ''}`;
|
|
135
|
+
settings.statusLine = { type: 'command', command, padding: 0 };
|
|
136
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
137
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
138
|
+
return { file, command, kept };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function uninstallStatusLine(opts = {}) {
|
|
142
|
+
const home = opts.home || claudeHome();
|
|
143
|
+
const file = settingsPath(home), prev = previousFile(home);
|
|
144
|
+
if (!fs.existsSync(file)) return { file, removed: false, restored: null };
|
|
145
|
+
const settings = JSON.parse(readTextFile(file));
|
|
146
|
+
if (!ours(settings.statusLine)) return { file, removed: false, restored: null };
|
|
147
|
+
fs.copyFileSync(file, file + '.glassbox-backup');
|
|
148
|
+
let restored = null;
|
|
149
|
+
if (fs.existsSync(prev)) { try { restored = JSON.parse(readTextFile(prev)).statusLine || null; } catch (e) { } fs.unlinkSync(prev); }
|
|
150
|
+
try { fs.unlinkSync(prev.replace(/\.json$/, '.last')); } catch (e) { }
|
|
151
|
+
if (restored) settings.statusLine = restored; else delete settings.statusLine;
|
|
152
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
153
|
+
return { file, removed: true, restored: restored ? restored.command || '(restored)' : null };
|
|
154
|
+
}
|