claude-usage-limits 1.11.7 → 1.13.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +133 -3
- package/bin/cli.js +1 -0
- package/commands/relay.md +45 -0
- package/commands/voice.md +33 -0
- package/hooks/hooks.json +24 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +130 -5
- package/skills/usage-limits/scripts/bars.js +56 -0
- package/skills/usage-limits/scripts/brief.js +257 -17
- package/skills/usage-limits/scripts/codex-lowpower.js +135 -0
- package/skills/usage-limits/scripts/codex.js +92 -22
- package/skills/usage-limits/scripts/feed.js +136 -5
- package/skills/usage-limits/scripts/install-codex-hook.js +71 -20
- package/skills/usage-limits/scripts/lowpower.js +10 -2
- package/skills/usage-limits/scripts/panel.js +180 -15
- package/skills/usage-limits/scripts/pulse.js +82 -22
- package/skills/usage-limits/scripts/reading.js +121 -0
- package/skills/usage-limits/scripts/recommend.js +16 -3
- package/skills/usage-limits/scripts/relay.js +859 -0
- package/skills/usage-limits/scripts/tally.js +7 -8
- package/skills/usage-limits/scripts/usage.js +842 -53
- package/skills/usage-limits/scripts/view.js +150 -8
- package/skills/usage-limits/scripts/voice.js +416 -0
- package/skills/usage-limits/scripts/wake.js +312 -0
|
@@ -24,6 +24,7 @@ const readline = require('readline');
|
|
|
24
24
|
const host = require('./host.js');
|
|
25
25
|
const codex = require('./codex.js');
|
|
26
26
|
const live = require('./live.js');
|
|
27
|
+
const reading = require('./reading.js');
|
|
27
28
|
|
|
28
29
|
// Which agent's meter to read. Resolved once from the command line or the
|
|
29
30
|
// environment, because a process that changed its mind halfway through would
|
|
@@ -439,6 +440,10 @@ function eventFrom(line, seen, project) {
|
|
|
439
440
|
const parts = tokenParts(entry.message.usage);
|
|
440
441
|
return {
|
|
441
442
|
at,
|
|
443
|
+
// Carried on the event so the scan cache can dedup across files without
|
|
444
|
+
// re-parsing them: a resumed or forked session repeats earlier turns, and
|
|
445
|
+
// a cached file is never read again to find that out.
|
|
446
|
+
dedupId: id === '|' ? null : id,
|
|
442
447
|
model: entry.message.model || '',
|
|
443
448
|
effort: entry.effort || null,
|
|
444
449
|
cost: costOf(entry.message.usage, entry.message.model),
|
|
@@ -479,9 +484,9 @@ function promptFrom(line) {
|
|
|
479
484
|
return typed;
|
|
480
485
|
}
|
|
481
486
|
|
|
482
|
-
async function readEvents(since) {
|
|
483
|
-
if (isCodex()) return codex.readEvents(since);
|
|
484
|
-
return readClaudeEvents(since);
|
|
487
|
+
async function readEvents(since, options) {
|
|
488
|
+
if (isCodex()) return codex.readEvents(since, options);
|
|
489
|
+
return readClaudeEvents(since, options);
|
|
485
490
|
}
|
|
486
491
|
|
|
487
492
|
// A file last touched before the window opened holds nothing useful.
|
|
@@ -529,7 +534,7 @@ function subagentTranscripts(dir, since, depth) {
|
|
|
529
534
|
return files;
|
|
530
535
|
}
|
|
531
536
|
|
|
532
|
-
|
|
537
|
+
function claudeTranscriptFiles(since) {
|
|
533
538
|
const root = path.join(configDir(), 'projects');
|
|
534
539
|
let dirs = [];
|
|
535
540
|
try {
|
|
@@ -564,26 +569,459 @@ async function readClaudeEvents(since) {
|
|
|
564
569
|
files.push({ file, project: dir.name });
|
|
565
570
|
}
|
|
566
571
|
}
|
|
572
|
+
return files;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// The effort level in force right now
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
//
|
|
579
|
+
// Claude Code stamps `effort` on every assistant line it writes, which makes
|
|
580
|
+
// the transcript the only source that is always current. It follows /effort
|
|
581
|
+
// the moment the model answers; it exists for a session that has no status
|
|
582
|
+
// line at all, which is every VS Code window; and it can say "max", which
|
|
583
|
+
// settings.json is not allowed to hold at all.
|
|
584
|
+
//
|
|
585
|
+
// Reading it off settings.json instead is what made the panel insist on
|
|
586
|
+
// "xhigh" through a whole session running at max, and made a session with no
|
|
587
|
+
// status line show the setting rather than the session.
|
|
588
|
+
//
|
|
589
|
+
// Only the tail is read, and only whole lines from it are parsed, so a
|
|
590
|
+
// megabyte is enough however long the transcript grows.
|
|
591
|
+
const EFFORT_TAIL_BYTES = 1024 * 1024;
|
|
592
|
+
const EFFORT_TAIL_LINES = 60;
|
|
593
|
+
|
|
594
|
+
function sessionTranscriptFile(sessionId) {
|
|
595
|
+
if (!sessionId) return null;
|
|
596
|
+
const root = path.join(configDir(), 'projects');
|
|
597
|
+
let dirs = [];
|
|
598
|
+
try {
|
|
599
|
+
dirs = fs.readdirSync(root, { withFileTypes: true });
|
|
600
|
+
} catch (err) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
let best = null;
|
|
604
|
+
for (const dir of dirs) {
|
|
605
|
+
if (!dir.isDirectory()) continue;
|
|
606
|
+
const file = path.join(root, dir.name, sessionId + '.jsonl');
|
|
607
|
+
let stat;
|
|
608
|
+
try {
|
|
609
|
+
stat = fs.statSync(file);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
// A session id is unique, but a resumed session can leave a copy under an
|
|
614
|
+
// older project directory; the one being written to is the live one.
|
|
615
|
+
if (!best || stat.mtimeMs > best.at) best = { file, at: stat.mtimeMs, size: stat.size };
|
|
616
|
+
}
|
|
617
|
+
return best;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The newest effort this session ran at, with the time it was stamped, so a
|
|
621
|
+
// caller holding a status-line reading can take whichever is newer.
|
|
622
|
+
function liveEffort(sessionId) {
|
|
623
|
+
const found = sessionTranscriptFile(sessionId);
|
|
624
|
+
if (!found) return null;
|
|
625
|
+
const from = Math.max(0, found.size - EFFORT_TAIL_BYTES);
|
|
626
|
+
const text = readSlice(found.file, from, found.size).toString('utf8');
|
|
627
|
+
const lines = text.split('\n');
|
|
628
|
+
// The first line of a mid-file slice is a fragment, and the last is whatever
|
|
629
|
+
// was half-written when the read happened. Neither is parsed.
|
|
630
|
+
const start = Math.max(from > 0 ? 1 : 0, lines.length - EFFORT_TAIL_LINES);
|
|
631
|
+
for (let i = lines.length - 1; i >= start; i -= 1) {
|
|
632
|
+
const line = lines[i];
|
|
633
|
+
if (!line || line.indexOf('"effort"') === -1) continue;
|
|
634
|
+
let entry;
|
|
635
|
+
try {
|
|
636
|
+
entry = JSON.parse(line);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
// A fragment, or the half-written tail.
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (!entry || typeof entry.effort !== 'string' || !entry.effort) continue;
|
|
642
|
+
const at = Date.parse(entry.timestamp);
|
|
643
|
+
return { effort: entry.effort, at: Number.isFinite(at) ? at : found.at };
|
|
644
|
+
}
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// The model a session is running, from the tail of its own transcript. Every
|
|
649
|
+
// assistant line carries message.model, so this is the session's word rather
|
|
650
|
+
// than a setting's or another window's. The VS Code panel has no status line
|
|
651
|
+
// of its own to ask, and until this existed it borrowed the newest status-line
|
|
652
|
+
// slot on the machine - which on 2026-09-08 was a 19-hour-old Opus session,
|
|
653
|
+
// shown over a Fable one.
|
|
654
|
+
function liveModel(sessionId) {
|
|
655
|
+
const found = sessionTranscriptFile(sessionId);
|
|
656
|
+
if (!found) return null;
|
|
657
|
+
const from = Math.max(0, found.size - EFFORT_TAIL_BYTES);
|
|
658
|
+
const text = readSlice(found.file, from, found.size).toString('utf8');
|
|
659
|
+
const lines = text.split('\n');
|
|
660
|
+
const start = Math.max(from > 0 ? 1 : 0, lines.length - EFFORT_TAIL_LINES);
|
|
661
|
+
for (let i = lines.length - 1; i >= start; i -= 1) {
|
|
662
|
+
const line = lines[i];
|
|
663
|
+
if (!line || line.indexOf('"assistant"') === -1 || line.indexOf('"model"') === -1) continue;
|
|
664
|
+
let entry;
|
|
665
|
+
try {
|
|
666
|
+
entry = JSON.parse(line);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const model = entry && entry.message && typeof entry.message.model === 'string' ? entry.message.model : null;
|
|
671
|
+
if (!model || model === '<synthetic>') continue;
|
|
672
|
+
const at = Date.parse(entry.timestamp);
|
|
673
|
+
return { model, at: Number.isFinite(at) ? at : found.at };
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// The scan cache
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
//
|
|
682
|
+
// Reading every transcript from the start, every time, is what the scan used to
|
|
683
|
+
// do, and it stopped being affordable the moment workflows arrived. One machine
|
|
684
|
+
// here had 950 subagent transcripts totalling 114 MB inside the eight-day
|
|
685
|
+
// window, and a full scan took 200 seconds. The prompt hook is given ten, so it
|
|
686
|
+
// was killed on every prompt and the reported percentage froze at whatever a
|
|
687
|
+
// cached view last said: 3% while the account was at 32%.
|
|
688
|
+
//
|
|
689
|
+
// Transcripts are append-only, and almost all of them are finished. So each
|
|
690
|
+
// file's parsed events are kept, keyed by its size and mtime, and a file that
|
|
691
|
+
// has grown is read only from where the last read stopped. The offset is
|
|
692
|
+
// counted in bytes, at the last complete line, so a half-written tail is simply
|
|
693
|
+
// read again next time.
|
|
694
|
+
const SCAN_VERSION = 1;
|
|
695
|
+
// A day wider than the widest window anything asks for, so a report never wants
|
|
696
|
+
// an event the cache has just pruned.
|
|
697
|
+
const SCAN_KEEP_MS = 9 * DAY;
|
|
698
|
+
// Enough for weeks of heavy use; the oldest go first if it is ever reached.
|
|
699
|
+
const SCAN_MAX_EVENTS = 250000;
|
|
700
|
+
|
|
701
|
+
function scanFile() {
|
|
702
|
+
return path.join(configDir(), 'usage-limits-scan.json');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function readScanCache() {
|
|
706
|
+
const parsed = readJson(scanFile());
|
|
707
|
+
if (!parsed || parsed.version !== SCAN_VERSION || !parsed.files || typeof parsed.files !== 'object') {
|
|
708
|
+
return { version: SCAN_VERSION, files: {} };
|
|
709
|
+
}
|
|
710
|
+
return { version: SCAN_VERSION, files: parsed.files };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Every state file the plugin shares between processes goes through here.
|
|
714
|
+
//
|
|
715
|
+
// Two windows run the same hooks at the same moment, and the status line runs
|
|
716
|
+
// every few hundred milliseconds. A plain writeFileSync is a truncate followed
|
|
717
|
+
// by a write, and a reader that lands between the two sees an empty or
|
|
718
|
+
// half-written file, parses nothing, and - for anything read, changed and
|
|
719
|
+
// written back, like the tally - then writes its own slot over everyone
|
|
720
|
+
// else's. Writing beside the file and renaming into place means every read
|
|
721
|
+
// sees either the old whole or the new whole. A rename Windows refuses leaves
|
|
722
|
+
// nothing behind, and the caller carries on with what is on disk.
|
|
723
|
+
function writeJsonAtomic(file, value) {
|
|
724
|
+
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
725
|
+
try {
|
|
726
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
727
|
+
fs.writeFileSync(temp, JSON.stringify(value), 'utf8');
|
|
728
|
+
fs.renameSync(temp, file);
|
|
729
|
+
return true;
|
|
730
|
+
} catch (err) {
|
|
731
|
+
try {
|
|
732
|
+
fs.unlinkSync(temp);
|
|
733
|
+
} catch (gone) {
|
|
734
|
+
// Nothing to clean up.
|
|
735
|
+
}
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Losing the scan cache costs one slow scan, never a wrong number.
|
|
741
|
+
function writeScanCache(cache) {
|
|
742
|
+
return writeJsonAtomic(scanFile(), cache);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// A byte range of a file, without pulling the whole thing into memory.
|
|
746
|
+
function readSlice(file, start, end) {
|
|
747
|
+
const length = Math.max(0, end - start);
|
|
748
|
+
if (!length) return Buffer.alloc(0);
|
|
749
|
+
let fd;
|
|
750
|
+
try {
|
|
751
|
+
fd = fs.openSync(file, 'r');
|
|
752
|
+
} catch (err) {
|
|
753
|
+
return Buffer.alloc(0);
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
757
|
+
let read = 0;
|
|
758
|
+
while (read < length) {
|
|
759
|
+
const got = fs.readSync(fd, buffer, read, length - read, start + read);
|
|
760
|
+
if (got <= 0) break;
|
|
761
|
+
read += got;
|
|
762
|
+
}
|
|
763
|
+
return read === length ? buffer : buffer.subarray(0, read);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
return Buffer.alloc(0);
|
|
766
|
+
} finally {
|
|
767
|
+
try {
|
|
768
|
+
fs.closeSync(fd);
|
|
769
|
+
} catch (err) {
|
|
770
|
+
// Already closed.
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Events are kept as tuples rather than objects, because in a file of ten
|
|
776
|
+
// thousand of them the key names alone were two thirds of the bytes. The
|
|
777
|
+
// project is a property of the file, so it is stored once on the entry and put
|
|
778
|
+
// back on the way out.
|
|
779
|
+
const ROW_TURN = 0;
|
|
780
|
+
const ROW_REFUSAL = 1;
|
|
781
|
+
|
|
782
|
+
function whole(value) {
|
|
783
|
+
const number = Number(value);
|
|
784
|
+
return Number.isFinite(number) ? number : 0;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function packEvent(event) {
|
|
788
|
+
if (event.rejected) {
|
|
789
|
+
const rejected = event.rejected;
|
|
790
|
+
return [
|
|
791
|
+
ROW_REFUSAL,
|
|
792
|
+
event.at,
|
|
793
|
+
event.sessionId || '',
|
|
794
|
+
rejected.status || '',
|
|
795
|
+
rejected.key || '',
|
|
796
|
+
Number.isFinite(rejected.resetsAt) ? rejected.resetsAt : 0,
|
|
797
|
+
];
|
|
798
|
+
}
|
|
799
|
+
const parts = event.parts || {};
|
|
800
|
+
return [
|
|
801
|
+
ROW_TURN,
|
|
802
|
+
event.at,
|
|
803
|
+
whole(event.cost),
|
|
804
|
+
whole(event.tokens),
|
|
805
|
+
whole(parts.input),
|
|
806
|
+
whole(parts.cacheWrite),
|
|
807
|
+
whole(parts.cacheRead),
|
|
808
|
+
whole(parts.output),
|
|
809
|
+
whole(parts.reasoning),
|
|
810
|
+
event.sidechain ? 1 : 0,
|
|
811
|
+
event.model || '',
|
|
812
|
+
event.sessionId || '',
|
|
813
|
+
event.effort || '',
|
|
814
|
+
event.dedupId || '',
|
|
815
|
+
];
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function unpackEvent(row, project) {
|
|
819
|
+
if (!Array.isArray(row)) return null;
|
|
820
|
+
if (row[0] === ROW_REFUSAL) {
|
|
821
|
+
return {
|
|
822
|
+
at: row[1],
|
|
823
|
+
model: '',
|
|
824
|
+
effort: null,
|
|
825
|
+
cost: 0,
|
|
826
|
+
tokens: 0,
|
|
827
|
+
parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 },
|
|
828
|
+
project: project || null,
|
|
829
|
+
sessionId: row[2] || null,
|
|
830
|
+
rejected: {
|
|
831
|
+
status: row[3] || null,
|
|
832
|
+
key: row[4] || null,
|
|
833
|
+
resetsAt: row[5] || null,
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
const parts = {
|
|
838
|
+
input: row[4],
|
|
839
|
+
cacheWrite: row[5],
|
|
840
|
+
cacheRead: row[6],
|
|
841
|
+
output: row[7],
|
|
842
|
+
reasoning: row[8],
|
|
843
|
+
};
|
|
844
|
+
return {
|
|
845
|
+
at: row[1],
|
|
846
|
+
dedupId: row[13] || null,
|
|
847
|
+
model: row[10] || '',
|
|
848
|
+
effort: row[12] || null,
|
|
849
|
+
cost: row[2],
|
|
850
|
+
tokens: row[3],
|
|
851
|
+
parts,
|
|
852
|
+
context: parts.input + parts.cacheRead + parts.cacheWrite,
|
|
853
|
+
project: project || null,
|
|
854
|
+
sessionId: row[11] || null,
|
|
855
|
+
sidechain: row[9] === 1,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Split on newlines in the buffer itself rather than after decoding, because
|
|
860
|
+
// the offset has to be a byte count: a transcript is full of characters that
|
|
861
|
+
// are more than one byte, and counting them as one would drift the offset and
|
|
862
|
+
// silently drop turns. A trailing partial line is left unconsumed.
|
|
863
|
+
function parseSlice(buffer, project, baseOffset) {
|
|
864
|
+
const events = [];
|
|
865
|
+
let start = 0;
|
|
866
|
+
let consumed = 0;
|
|
867
|
+
for (let i = 0; i < buffer.length; i += 1) {
|
|
868
|
+
if (buffer[i] !== 0x0a) continue;
|
|
869
|
+
const event = eventFrom(buffer.toString('utf8', start, i), null, project);
|
|
870
|
+
if (event) events.push(event);
|
|
871
|
+
start = i + 1;
|
|
872
|
+
consumed = start;
|
|
873
|
+
}
|
|
874
|
+
return { events, offset: baseOffset + consumed };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// One file's events, reusing whatever the cache already holds of it.
|
|
878
|
+
function eventsForFile(entry, cache, keepFrom) {
|
|
879
|
+
let stat;
|
|
880
|
+
try {
|
|
881
|
+
stat = fs.statSync(entry.file);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
delete cache.files[entry.file];
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const cached = cache.files[entry.file];
|
|
888
|
+
const usable =
|
|
889
|
+
cached &&
|
|
890
|
+
Array.isArray(cached.rows) &&
|
|
891
|
+
Number.isFinite(cached.offset) &&
|
|
892
|
+
Number.isFinite(cached.size) &&
|
|
893
|
+
// Appended to, or untouched. Anything else - a rewrite, a truncation, a
|
|
894
|
+
// clock that went backwards - is read again from the beginning, because
|
|
895
|
+
// the offset can no longer be trusted to point where it says.
|
|
896
|
+
stat.size >= cached.size &&
|
|
897
|
+
cached.offset <= stat.size;
|
|
898
|
+
|
|
899
|
+
if (usable && stat.size === cached.size && stat.mtimeMs === cached.mtimeMs) {
|
|
900
|
+
return { rows: cached.rows, project: cached.project || entry.project, changed: false };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const from = usable ? cached.offset : 0;
|
|
904
|
+
const parsed = parseSlice(readSlice(entry.file, from, stat.size), entry.project, from);
|
|
905
|
+
const rows = (usable ? cached.rows : [])
|
|
906
|
+
.concat(parsed.events.map(packEvent))
|
|
907
|
+
.filter((row) => row[1] >= keepFrom);
|
|
908
|
+
cache.files[entry.file] = {
|
|
909
|
+
mtimeMs: stat.mtimeMs,
|
|
910
|
+
size: stat.size,
|
|
911
|
+
offset: parsed.offset,
|
|
912
|
+
project: entry.project,
|
|
913
|
+
rows,
|
|
914
|
+
};
|
|
915
|
+
return { rows, project: entry.project, changed: true };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function readClaudeEvents(since, options) {
|
|
919
|
+
const opts = options || {};
|
|
920
|
+
const startedAt = Date.now();
|
|
921
|
+
// Zero is a real budget - spent before the first file - not "no budget";
|
|
922
|
+
// absent or negative means unlimited. A test relies on zero being exact,
|
|
923
|
+
// which Date.now() cannot promise: a scan of a few small files fits inside
|
|
924
|
+
// one millisecond tick, so "elapsed > 0" stayed false all the way through
|
|
925
|
+
// and the scan reported a complete total it had no right to. The budget is
|
|
926
|
+
// measured on the monotonic sub-millisecond clock, and spent means reached.
|
|
927
|
+
const clock = performance.now();
|
|
928
|
+
const budgetMs = Number.isFinite(opts.budgetMs) && opts.budgetMs >= 0 ? opts.budgetMs : null;
|
|
929
|
+
const keepFrom = Math.min(since, startedAt - SCAN_KEEP_MS);
|
|
930
|
+
|
|
931
|
+
const files = claudeTranscriptFiles(since);
|
|
932
|
+
// Newest first, so a scan that runs out of time has done the files that
|
|
933
|
+
// describe the window running now rather than the ones from last Tuesday.
|
|
934
|
+
const ordered = files
|
|
935
|
+
.map((entry) => {
|
|
936
|
+
let at = 0;
|
|
937
|
+
try {
|
|
938
|
+
at = fs.statSync(entry.file).mtimeMs;
|
|
939
|
+
} catch (err) {
|
|
940
|
+
at = 0;
|
|
941
|
+
}
|
|
942
|
+
return Object.assign({ at }, entry);
|
|
943
|
+
})
|
|
944
|
+
.sort((a, b) => b.at - a.at);
|
|
945
|
+
|
|
946
|
+
const cache = opts.cache === false ? { version: SCAN_VERSION, files: {} } : readScanCache();
|
|
947
|
+
const collected = [];
|
|
948
|
+
const alive = new Set();
|
|
949
|
+
let changed = false;
|
|
950
|
+
let partial = false;
|
|
951
|
+
|
|
952
|
+
for (const entry of ordered) {
|
|
953
|
+
if (budgetMs !== null && performance.now() - clock >= budgetMs) {
|
|
954
|
+
partial = true;
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
alive.add(entry.file);
|
|
958
|
+
const result = eventsForFile(entry, cache, keepFrom);
|
|
959
|
+
if (!result) {
|
|
960
|
+
changed = true;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (result.changed) changed = true;
|
|
964
|
+
collected.push(result);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Files that have gone cold or been deleted, and anything left over the cap.
|
|
968
|
+
if (opts.cache !== false) {
|
|
969
|
+
let total = 0;
|
|
970
|
+
for (const file of Object.keys(cache.files)) {
|
|
971
|
+
const held = cache.files[file];
|
|
972
|
+
if (!held || !Array.isArray(held.rows)) {
|
|
973
|
+
delete cache.files[file];
|
|
974
|
+
changed = true;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
// Past the window is gone whatever happened. An entry the scan merely
|
|
978
|
+
// did not reach is dropped only when the scan was complete: a partial
|
|
979
|
+
// one has not seen every file and must not be what decides they are dead.
|
|
980
|
+
const tooOld = !(Number.isFinite(held.mtimeMs) && held.mtimeMs >= keepFrom);
|
|
981
|
+
if (tooOld || (!partial && !alive.has(file))) {
|
|
982
|
+
delete cache.files[file];
|
|
983
|
+
changed = true;
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
total += held.rows.length;
|
|
987
|
+
}
|
|
988
|
+
if (total > SCAN_MAX_EVENTS) {
|
|
989
|
+
const oldest = Object.keys(cache.files).sort(
|
|
990
|
+
(a, b) => (cache.files[a].mtimeMs || 0) - (cache.files[b].mtimeMs || 0)
|
|
991
|
+
);
|
|
992
|
+
for (const file of oldest) {
|
|
993
|
+
if (total <= SCAN_MAX_EVENTS) break;
|
|
994
|
+
total -= cache.files[file].rows.length;
|
|
995
|
+
delete cache.files[file];
|
|
996
|
+
changed = true;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// Through a temporary file named for this process, so two windows running
|
|
1000
|
+
// the hook at the same moment cannot interleave a half-written cache.
|
|
1001
|
+
if (changed) writeScanCache(cache);
|
|
1002
|
+
}
|
|
567
1003
|
|
|
568
1004
|
const seen = new Set();
|
|
569
1005
|
const events = [];
|
|
570
|
-
for (const
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
1006
|
+
for (const held of collected) {
|
|
1007
|
+
for (const row of held.rows) {
|
|
1008
|
+
if (row[1] < since) continue;
|
|
1009
|
+
const id = row[0] === ROW_TURN ? row[13] : '';
|
|
1010
|
+
if (id) {
|
|
1011
|
+
if (seen.has(id)) continue;
|
|
1012
|
+
seen.add(id);
|
|
577
1013
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
} finally {
|
|
581
|
-
lines.close();
|
|
582
|
-
stream.destroy();
|
|
1014
|
+
const event = unpackEvent(row, held.project);
|
|
1015
|
+
if (event) events.push(event);
|
|
583
1016
|
}
|
|
584
1017
|
}
|
|
585
1018
|
|
|
586
1019
|
events.sort((a, b) => a.at - b.at);
|
|
1020
|
+
if (partial) {
|
|
1021
|
+
// Non-enumerable, so nothing that iterates or serialises the events can
|
|
1022
|
+
// trip over it; the report reads it to say the correction may be short.
|
|
1023
|
+
Object.defineProperty(events, 'partial', { value: true, enumerable: false });
|
|
1024
|
+
}
|
|
587
1025
|
return events;
|
|
588
1026
|
}
|
|
589
1027
|
|
|
@@ -656,6 +1094,12 @@ const MIN_PACE_SAMPLE = 5;
|
|
|
656
1094
|
// ordinary turn, and treating that as "the" turn cost sends the headroom
|
|
657
1095
|
// estimate swinging: a single $7 turn once put a 13% full window at nine turns
|
|
658
1096
|
// left. Too few recent turns to be sure, so widen to the whole window.
|
|
1097
|
+
// A turn that fanned out ten agents is real, and so is the budget it spent, but
|
|
1098
|
+
// it should not price every remaining turn as though it will do the same. Five
|
|
1099
|
+
// is generous enough to catch a habitually agent-heavy session and mean enough
|
|
1100
|
+
// that one workflow does not flatten the estimate to nothing.
|
|
1101
|
+
const SUBAGENT_FACTOR_MAX = 5;
|
|
1102
|
+
|
|
659
1103
|
function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
|
|
660
1104
|
const floor = Number.isFinite(minSample) ? minSample : MIN_PACE_SAMPLE;
|
|
661
1105
|
|
|
@@ -663,14 +1107,20 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
|
|
|
663
1107
|
// is being measured against, so a thin window borrows from a wider sample
|
|
664
1108
|
// rather than inventing a figure from two turns. Subagent calls are left
|
|
665
1109
|
// out: they are small and many, and would make a turn look cheap.
|
|
666
|
-
const
|
|
1110
|
+
const sources = [recentEvents, windowEvents, allEvents];
|
|
1111
|
+
const tiers = sources.map((tier) => mainThread(tier));
|
|
667
1112
|
let pool = [];
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1113
|
+
let source = null;
|
|
1114
|
+
for (let i = 0; i < tiers.length; i++) {
|
|
1115
|
+
if (tiers[i] && tiers[i].length >= floor) {
|
|
1116
|
+
pool = tiers[i];
|
|
1117
|
+
source = sources[i];
|
|
671
1118
|
break;
|
|
672
1119
|
}
|
|
673
|
-
if (
|
|
1120
|
+
if (tiers[i] && tiers[i].length > pool.length) {
|
|
1121
|
+
pool = tiers[i];
|
|
1122
|
+
source = sources[i];
|
|
1123
|
+
}
|
|
674
1124
|
}
|
|
675
1125
|
const costs = pool
|
|
676
1126
|
.map((event) => event.cost)
|
|
@@ -692,7 +1142,34 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
|
|
|
692
1142
|
const cut = costs.length >= MIN_PACE_SAMPLE ? Math.max(1, Math.round(costs.length * 0.1)) : 0;
|
|
693
1143
|
const kept = cut > 0 ? costs.slice(cut, costs.length - cut) : costs;
|
|
694
1144
|
const middle = kept.length ? kept : costs;
|
|
695
|
-
|
|
1145
|
+
const perMainThreadTurn = middle.reduce((sum, cost) => sum + cost, 0) / middle.length;
|
|
1146
|
+
|
|
1147
|
+
// And then the part that was missing, which is why a fan-out session was
|
|
1148
|
+
// promised four hundred turns and got sixty.
|
|
1149
|
+
//
|
|
1150
|
+
// Subagent calls are excluded from the sample above for a good reason: they
|
|
1151
|
+
// are small and many, and counting each as a turn makes a turn look cheap.
|
|
1152
|
+
// But their spend does not disappear - it comes out of the same window. A
|
|
1153
|
+
// turn that dispatches three research agents costs what the agents cost, and
|
|
1154
|
+
// measuring only its main-thread half prices it as though they were free.
|
|
1155
|
+
//
|
|
1156
|
+
// Rather than attributing each call to a parent turn, which the transcripts
|
|
1157
|
+
// do not reliably say, scale by how much of this pool's spend the main
|
|
1158
|
+
// thread actually accounts for. If subagents were two thirds of it, a turn
|
|
1159
|
+
// costs three times its visible half. Clamped, because a single enormous
|
|
1160
|
+
// fan-out should not price every future turn as another one.
|
|
1161
|
+
const total = sumCost(source);
|
|
1162
|
+
const visible = sumCost(pool);
|
|
1163
|
+
const factor = visible > 0 && total > visible ? Math.min(SUBAGENT_FACTOR_MAX, total / visible) : 1;
|
|
1164
|
+
return perMainThreadTurn * factor;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function sumCost(events) {
|
|
1168
|
+
let total = 0;
|
|
1169
|
+
for (const event of events || []) {
|
|
1170
|
+
if (event && Number.isFinite(event.cost) && event.cost > 0) total += event.cost;
|
|
1171
|
+
}
|
|
1172
|
+
return total;
|
|
696
1173
|
}
|
|
697
1174
|
|
|
698
1175
|
function dominantEffort(events) {
|
|
@@ -708,6 +1185,142 @@ function dominantEffort(events) {
|
|
|
708
1185
|
return best ? best[0] : null;
|
|
709
1186
|
}
|
|
710
1187
|
|
|
1188
|
+
// What a turn has actually cost at each effort level, measured separately.
|
|
1189
|
+
//
|
|
1190
|
+
// The blended cost per turn is the right answer to "what has this been
|
|
1191
|
+
// costing" and the wrong one the moment the effort changes. An account that
|
|
1192
|
+
// usually runs at medium and switches to ultra is priced at the medium rate
|
|
1193
|
+
// until enough ultra turns have landed to drag the average up - and on a plan
|
|
1194
|
+
// with a small window there is no "enough", because the window is gone first.
|
|
1195
|
+
//
|
|
1196
|
+
// That is not hypothetical either. A ChatGPT Plus account running gpt-6-astra
|
|
1197
|
+
// at ultra effort emptied a whole five-hour window on one ordinary task, with
|
|
1198
|
+
// this plugin reporting room the entire way, because every turn it had on
|
|
1199
|
+
// record was a cheaper one.
|
|
1200
|
+
//
|
|
1201
|
+
// Subagent calls are left out for the same reason turns leave them out: they
|
|
1202
|
+
// are errands, not turns, and averaging them in makes a turn look cheap.
|
|
1203
|
+
function effortRates(events) {
|
|
1204
|
+
const rows = new Map();
|
|
1205
|
+
for (const event of events || []) {
|
|
1206
|
+
if (!event || event.rejected || event.sidechain) continue;
|
|
1207
|
+
if (!event.effort) continue;
|
|
1208
|
+
if (!rows.has(event.effort)) {
|
|
1209
|
+
rows.set(event.effort, { effort: event.effort, turns: 0, cost: 0, tokens: 0, output: 0, reasoning: 0 });
|
|
1210
|
+
}
|
|
1211
|
+
const row = rows.get(event.effort);
|
|
1212
|
+
const parts = event.parts || {};
|
|
1213
|
+
row.turns += 1;
|
|
1214
|
+
row.cost += Number.isFinite(event.cost) ? event.cost : 0;
|
|
1215
|
+
row.tokens += Number.isFinite(event.tokens) ? event.tokens : 0;
|
|
1216
|
+
row.output += Number.isFinite(parts.output) ? parts.output : 0;
|
|
1217
|
+
row.reasoning += Number.isFinite(parts.reasoning) ? parts.reasoning : 0;
|
|
1218
|
+
}
|
|
1219
|
+
return [...rows.values()]
|
|
1220
|
+
.map((row) =>
|
|
1221
|
+
Object.assign(row, {
|
|
1222
|
+
perTurn: row.turns ? row.cost / row.turns : null,
|
|
1223
|
+
// What the effort setting actually moves. Cost per turn is dominated by
|
|
1224
|
+
// how big the context happened to be, which is why measuring it that
|
|
1225
|
+
// way can report "low" as dearer than "ultra": a small ultra turn on a
|
|
1226
|
+
// short context really did cost less than a huge low-effort one. The
|
|
1227
|
+
// output is the part the setting controls, and it is the dear part -
|
|
1228
|
+
// eight times the weight of fresh input on Codex's own meter.
|
|
1229
|
+
outputPerTurn: row.turns ? row.output / row.turns : null,
|
|
1230
|
+
reasoningPerTurn: row.turns ? row.reasoning / row.turns : null,
|
|
1231
|
+
})
|
|
1232
|
+
)
|
|
1233
|
+
.sort((a, b) => (a.outputPerTurn || 0) - (b.outputPerTurn || 0));
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Enough turns at one effort to believe the figure at all.
|
|
1237
|
+
const MIN_EFFORT_SAMPLE = 3;
|
|
1238
|
+
// Dearer than the cheapest measured effort by this much before it is worth
|
|
1239
|
+
// saying anything. Below it the setting is not what is spending the budget.
|
|
1240
|
+
const EFFORT_DEARER_BY = 1.5;
|
|
1241
|
+
// At or under this many turns left at the CURRENT effort, say so whether or
|
|
1242
|
+
// not a cheaper effort has ever been measured.
|
|
1243
|
+
const FEW_TURNS_AT_EFFORT = 12;
|
|
1244
|
+
|
|
1245
|
+
// The other agent's meter, for the surfaces that draw both.
|
|
1246
|
+
//
|
|
1247
|
+
// view.js is required at call time rather than at the top of the file: it
|
|
1248
|
+
// requires this module back, and a cycle resolved at load time would hand it a
|
|
1249
|
+
// half-built exports object. By the time a report is being built both are
|
|
1250
|
+
// finished loading.
|
|
1251
|
+
function codexBlock(now) {
|
|
1252
|
+
if (isCodex()) return null;
|
|
1253
|
+
try {
|
|
1254
|
+
if (!host.codexHasSessions()) return null;
|
|
1255
|
+
const display = require('./view.js');
|
|
1256
|
+
const other = codex.collect(now);
|
|
1257
|
+
const block = display.buildCodex({
|
|
1258
|
+
now,
|
|
1259
|
+
utilization: other.utilization,
|
|
1260
|
+
fetchedAtMs: other.snapshotFetchedAt,
|
|
1261
|
+
windowSpecs: other.windowSpecs,
|
|
1262
|
+
plan: other.plan,
|
|
1263
|
+
windowless: other.windowless,
|
|
1264
|
+
unreadable: other.unreadable,
|
|
1265
|
+
});
|
|
1266
|
+
return block.present ? block : null;
|
|
1267
|
+
} catch (err) {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// The warning that would have caught that incident: what the window holds at
|
|
1273
|
+
// the effort actually set, rather than at the average of everything ever run.
|
|
1274
|
+
function effortWarning(events, current, window) {
|
|
1275
|
+
if (!current || !window || window.stale) return null;
|
|
1276
|
+
const rates = effortRates(events);
|
|
1277
|
+
const here = rates.find((row) => row.effort === current);
|
|
1278
|
+
if (!here || here.turns < MIN_EFFORT_SAMPLE || !here.perTurn || here.perTurn <= 0) return null;
|
|
1279
|
+
|
|
1280
|
+
// Compared on output per turn, because that is what the effort setting
|
|
1281
|
+
// moves; cost per turn is mostly a fact about how long the context was.
|
|
1282
|
+
const measure = (row) => (Number.isFinite(row.outputPerTurn) && row.outputPerTurn > 0 ? row.outputPerTurn : null);
|
|
1283
|
+
const mine = measure(here);
|
|
1284
|
+
const cheaper = mine
|
|
1285
|
+
? rates.find(
|
|
1286
|
+
(row) =>
|
|
1287
|
+
row.effort !== current &&
|
|
1288
|
+
row.turns >= MIN_EFFORT_SAMPLE &&
|
|
1289
|
+
measure(row) &&
|
|
1290
|
+
mine / measure(row) >= EFFORT_DEARER_BY
|
|
1291
|
+
)
|
|
1292
|
+
: null;
|
|
1293
|
+
|
|
1294
|
+
const turnsLeft =
|
|
1295
|
+
Number.isFinite(window.usdPerPercent) && window.usdPerPercent > 0 && Number.isFinite(window.percentLeft)
|
|
1296
|
+
? Math.max(0, Math.floor((window.percentLeft * window.usdPerPercent) / here.perTurn))
|
|
1297
|
+
: null;
|
|
1298
|
+
|
|
1299
|
+
// Nothing to say when the effort is not the dear one and the window is not
|
|
1300
|
+
// nearly out at it.
|
|
1301
|
+
if (!cheaper && (turnsLeft === null || turnsLeft > FEW_TURNS_AT_EFFORT)) return null;
|
|
1302
|
+
|
|
1303
|
+
return {
|
|
1304
|
+
effort: current,
|
|
1305
|
+
perTurn: here.perTurn,
|
|
1306
|
+
sample: here.turns,
|
|
1307
|
+
turnsLeft,
|
|
1308
|
+
blendedTurnsLeft: Number.isFinite(window.turnsLeft) ? window.turnsLeft : null,
|
|
1309
|
+
outputPerTurn: here.outputPerTurn,
|
|
1310
|
+
cheaper: cheaper
|
|
1311
|
+
? {
|
|
1312
|
+
effort: cheaper.effort,
|
|
1313
|
+
perTurn: cheaper.perTurn,
|
|
1314
|
+
outputPerTurn: cheaper.outputPerTurn,
|
|
1315
|
+
// How many times more the current setting writes per turn. This is
|
|
1316
|
+
// the number worth saying out loud: "ultra writes 6x the output of
|
|
1317
|
+
// medium" is what makes someone change it.
|
|
1318
|
+
multiple: measure(cheaper) ? mine / measure(cheaper) : null,
|
|
1319
|
+
}
|
|
1320
|
+
: null,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
711
1324
|
// What the thinking actually cost, rather than what it is generally said to
|
|
712
1325
|
// cost. Reasoning is billed as output, so it is priced at the output rate of
|
|
713
1326
|
// whichever models did the thinking, weighted by how much each of them did.
|
|
@@ -1028,8 +1641,7 @@ function readCalibration() {
|
|
|
1028
1641
|
|
|
1029
1642
|
function writeCalibration(all) {
|
|
1030
1643
|
try {
|
|
1031
|
-
|
|
1032
|
-
fs.writeFileSync(calibrationFile(), JSON.stringify(all), 'utf8');
|
|
1644
|
+
writeJsonAtomic(calibrationFile(), all);
|
|
1033
1645
|
} catch (err) {
|
|
1034
1646
|
// Losing it costs accuracy on the next thin baseline, nothing more.
|
|
1035
1647
|
}
|
|
@@ -1063,8 +1675,7 @@ function readModelRecord() {
|
|
|
1063
1675
|
|
|
1064
1676
|
function writeModelRecord(all) {
|
|
1065
1677
|
try {
|
|
1066
|
-
|
|
1067
|
-
fs.writeFileSync(modelRecordFile(), JSON.stringify(all), 'utf8');
|
|
1678
|
+
writeJsonAtomic(modelRecordFile(), all);
|
|
1068
1679
|
} catch (err) {
|
|
1069
1680
|
// The record is a convenience for a thin week, not a source of truth.
|
|
1070
1681
|
}
|
|
@@ -1152,19 +1763,24 @@ function stampPlan(all, planId) {
|
|
|
1152
1763
|
// Turn count only breaks the tie: it says how much local spend sat behind the
|
|
1153
1764
|
// reading, not how precise the denominator was, and preferring it outright is
|
|
1154
1765
|
// how a 44-turn baseline read at 1% once beat every honest sample after it.
|
|
1766
|
+
// Which of two prices to keep, and which to write back to disk.
|
|
1767
|
+
//
|
|
1768
|
+
// A wider reading is a better measurement, so percent still ranks - but only
|
|
1769
|
+
// between two readings that are measurements at all. An entry learned at either
|
|
1770
|
+
// end of the meter is not one, and ranking on percent alone meant a price
|
|
1771
|
+
// learned at 100 could never be displaced by an honest one from the middle:
|
|
1772
|
+
// 100 is the highest number there is, so it won every comparison and was
|
|
1773
|
+
// written back for ever. The file could not heal itself even once the readers
|
|
1774
|
+
// had started ignoring it.
|
|
1155
1775
|
function betterCalibration(current, candidate) {
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
if (!
|
|
1160
|
-
if (
|
|
1161
|
-
|
|
1162
|
-
Number.isFinite(current.percent) &&
|
|
1163
|
-
candidate.percent !== current.percent
|
|
1164
|
-
) {
|
|
1165
|
-
return candidate.percent > current.percent ? candidate : current;
|
|
1776
|
+
const fresh = usableCalibration(candidate);
|
|
1777
|
+
const held = usableCalibration(current);
|
|
1778
|
+
if (!fresh) return held || null;
|
|
1779
|
+
if (!held || !Number.isFinite(held.turns)) return fresh;
|
|
1780
|
+
if (Number.isFinite(fresh.percent) && Number.isFinite(held.percent) && fresh.percent !== held.percent) {
|
|
1781
|
+
return fresh.percent > held.percent ? fresh : held;
|
|
1166
1782
|
}
|
|
1167
|
-
return
|
|
1783
|
+
return fresh.turns > held.turns ? fresh : held;
|
|
1168
1784
|
}
|
|
1169
1785
|
|
|
1170
1786
|
// Everything the report needs about one limit window.
|
|
@@ -1285,22 +1901,19 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
1285
1901
|
// holding. The learned price covers it: what a point costs is a property
|
|
1286
1902
|
// of the plan, not of this reading.
|
|
1287
1903
|
const selfPriced =
|
|
1288
|
-
rawPercent >= MIN_BASELINE_PERCENT &&
|
|
1904
|
+
rawPercent >= MIN_BASELINE_PERCENT &&
|
|
1905
|
+
rawPercent <= MAX_BASELINE_PERCENT &&
|
|
1906
|
+
upTo.cost > 0 &&
|
|
1907
|
+
upTo.turns >= MIN_BASELINE_TURNS
|
|
1289
1908
|
? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
|
|
1290
1909
|
: null;
|
|
1291
1910
|
// A metered window has nothing to learn: its price per point is stated.
|
|
1292
1911
|
if (selfPriced && !extra.metered) window.calibration = selfPriced;
|
|
1293
1912
|
|
|
1294
|
-
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
const usable =
|
|
1298
|
-
known &&
|
|
1299
|
-
Number.isFinite(known.usdPerPercent) &&
|
|
1300
|
-
known.usdPerPercent > 0 &&
|
|
1301
|
-
!(Number.isFinite(known.percent) && known.percent < MIN_BASELINE_PERCENT)
|
|
1302
|
-
? known
|
|
1303
|
-
: null;
|
|
1913
|
+
// A remembered price is only as good as the reading it was learned from,
|
|
1914
|
+
// and both ends of the meter lie. One gate, shared with the headroom
|
|
1915
|
+
// arithmetic below.
|
|
1916
|
+
const usable = usableCalibration(extra.knownCalibration);
|
|
1304
1917
|
// Trust the better-measured of the two, whichever that is; a stated price
|
|
1305
1918
|
// beats both.
|
|
1306
1919
|
const stated =
|
|
@@ -1349,10 +1962,17 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
1349
1962
|
// window reading 1% divided a full hour of spend by one and priced the
|
|
1350
1963
|
// remaining 99 points at thousands of dollars. Below the floor the learned
|
|
1351
1964
|
// price takes over through the fallback chain.
|
|
1965
|
+
// The ceiling is the same rule the other way up: at 100 the meter has
|
|
1966
|
+
// stopped counting, so the spend past it divides against points that were
|
|
1967
|
+
// never registered and every remaining point looks cheaper than it is.
|
|
1352
1968
|
const measured =
|
|
1353
|
-
percent !== null &&
|
|
1969
|
+
percent !== null &&
|
|
1970
|
+
percent >= MIN_BASELINE_PERCENT &&
|
|
1971
|
+
percent <= MAX_BASELINE_PERCENT &&
|
|
1972
|
+
spent.cost > 0 &&
|
|
1973
|
+
!thin;
|
|
1354
1974
|
const derived = measured ? spent.cost / percent : null;
|
|
1355
|
-
const known = extra.knownCalibration;
|
|
1975
|
+
const known = usableCalibration(extra.knownCalibration);
|
|
1356
1976
|
const metered =
|
|
1357
1977
|
Boolean(extra.metered) && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0;
|
|
1358
1978
|
const priced = metered
|
|
@@ -1387,6 +2007,22 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
1387
2007
|
}
|
|
1388
2008
|
}
|
|
1389
2009
|
|
|
2010
|
+
// When spending has gone further past the snapshot than the snapshot said was
|
|
2011
|
+
// left, the percentage is a floor and the price per point was derived from a
|
|
2012
|
+
// denominator that is no longer true. Everything downstream of it is worse
|
|
2013
|
+
// than useless: measured live, the line read "13 per cent used, about 1301
|
|
2014
|
+
// turns of headroom" in the same breath as warning that the window was
|
|
2015
|
+
// probably already exhausted. A number that confident, that wrong, beside its
|
|
2016
|
+
// own contradiction, is the failure this whole plugin exists to avoid.
|
|
2017
|
+
//
|
|
2018
|
+
// There is no honest count to print here, so none is printed. The warning
|
|
2019
|
+
// that the snapshot has been outrun stands on its own.
|
|
2020
|
+
if (window.pointsBeyondSnapshot) {
|
|
2021
|
+
window.turnsLeft = null;
|
|
2022
|
+
window.headroomMs = null;
|
|
2023
|
+
window.remainingUSD = null;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
1390
2026
|
// A reset time in the past means the window already turned over and the
|
|
1391
2027
|
// cached percentage describes a window that no longer exists. Reporting it
|
|
1392
2028
|
// as current would claim the budget is gone when it has just come back.
|
|
@@ -1730,6 +2366,36 @@ const MIN_BASELINE_TURNS = 5;
|
|
|
1730
2366
|
// truly at 35.
|
|
1731
2367
|
const MIN_BASELINE_PERCENT = 5;
|
|
1732
2368
|
|
|
2369
|
+
// And a reading near the top cannot price one either, for the opposite reason.
|
|
2370
|
+
//
|
|
2371
|
+
// The price of a point is the spend inside the window divided by the meter's
|
|
2372
|
+
// own percentage, and that arithmetic assumes the meter is still counting. It
|
|
2373
|
+
// stops at 100. Everything spent past the cap is real money that moved no
|
|
2374
|
+
// points, so dividing by 100 counts it against points that were never
|
|
2375
|
+
// registered and the price comes out too cheap - which then converts later
|
|
2376
|
+
// spend into far more points than it moved, and the reported percentage
|
|
2377
|
+
// overshoots the account.
|
|
2378
|
+
//
|
|
2379
|
+
// Measured here on 2026-09-07: a five-hour price learned at a reading of 100
|
|
2380
|
+
// said a point cost $0.67, while the same window measured between two live
|
|
2381
|
+
// readings in the healthy range - 24% to 47% on $20.85 - said $0.91. Thirty-six
|
|
2382
|
+
// per cent too cheap, and the display was reading 54% against an account at 47.
|
|
2383
|
+
const MAX_BASELINE_PERCENT = 95;
|
|
2384
|
+
|
|
2385
|
+
// Whether a remembered price may be used at all.
|
|
2386
|
+
//
|
|
2387
|
+
// Defined once because the question is asked in two different places, and
|
|
2388
|
+
// gating only one of them was exactly how the capped price kept getting
|
|
2389
|
+
// through: the correction refused it and the headroom arithmetic took it
|
|
2390
|
+
// anyway. A price with no reading recorded against it predates this check and
|
|
2391
|
+
// is taken on trust; a recorded one has to be from the middle of the meter.
|
|
2392
|
+
function usableCalibration(known) {
|
|
2393
|
+
if (!known || !Number.isFinite(known.usdPerPercent) || known.usdPerPercent <= 0) return null;
|
|
2394
|
+
if (!Number.isFinite(known.percent)) return known;
|
|
2395
|
+
if (known.percent < MIN_BASELINE_PERCENT || known.percent > MAX_BASELINE_PERCENT) return null;
|
|
2396
|
+
return known;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
1733
2399
|
// Past this much of a window, a handful of local turns is not what spent it,
|
|
1734
2400
|
// so their total is not a fair price for a point.
|
|
1735
2401
|
const UNEXPLAINED_PERCENT = 20;
|
|
@@ -1954,7 +2620,11 @@ async function report(now, options) {
|
|
|
1954
2620
|
// A stale snapshot can put a window's start slightly further back than
|
|
1955
2621
|
// seven days, so give the scan a day of slack.
|
|
1956
2622
|
const earliest = now - 8 * DAY;
|
|
1957
|
-
|
|
2623
|
+
// A hook is given ten seconds; the panel and the CLI have all the time they
|
|
2624
|
+
// want. A scan that runs out of its budget returns what it managed to read
|
|
2625
|
+
// and says so, which is how the report knows its correction may be short.
|
|
2626
|
+
const all = await readEvents(earliest, options && options.budgetMs ? { budgetMs: options.budgetMs } : undefined);
|
|
2627
|
+
const scanPartial = Boolean(all && all.partial);
|
|
1958
2628
|
// A refused request is a record of the limit, not a turn against it, so it is
|
|
1959
2629
|
// kept apart from everything that measures spend or pace.
|
|
1960
2630
|
const rejections = lastRejections(all);
|
|
@@ -2043,6 +2713,20 @@ async function report(now, options) {
|
|
|
2043
2713
|
const recent = totals(recentEvents);
|
|
2044
2714
|
|
|
2045
2715
|
const binding = bindingWindow(windows);
|
|
2716
|
+
|
|
2717
|
+
// The effort the next turn will run at. Codex states it outright in
|
|
2718
|
+
// config.toml; Claude Code stamps it on every assistant line, which is the
|
|
2719
|
+
// only source that follows /effort mid-session. The setting is last.
|
|
2720
|
+
let effortNow = (options && options.effort) || null;
|
|
2721
|
+
if (!effortNow && !isCodex() && options && options.sessionId) {
|
|
2722
|
+
const seen = liveEffort(options.sessionId);
|
|
2723
|
+
if (seen && seen.effort) effortNow = seen.effort;
|
|
2724
|
+
}
|
|
2725
|
+
if (!effortNow) {
|
|
2726
|
+
const configured = base.settings && base.settings.effortLevel;
|
|
2727
|
+
if (configured && configured !== 'default') effortNow = configured;
|
|
2728
|
+
}
|
|
2729
|
+
if (!effortNow) effortNow = dominantEffort(recentEvents) || null;
|
|
2046
2730
|
const scopeStart = binding ? binding.windowStart : now - 7 * DAY;
|
|
2047
2731
|
const scoped = events.filter((event) => event.at >= scopeStart && event.at <= now);
|
|
2048
2732
|
const scopedTotals = totals(scoped);
|
|
@@ -2086,6 +2770,17 @@ async function report(now, options) {
|
|
|
2086
2770
|
},
|
|
2087
2771
|
measuredTurns: mainThread(events).length,
|
|
2088
2772
|
subagentTurns: events.length - mainThread(events).length,
|
|
2773
|
+
// The effort the NEXT turn will run at, and what a turn has cost at each
|
|
2774
|
+
// effort on record. Codex states it in config.toml; Claude Code stamps it
|
|
2775
|
+
// on every line of the transcript.
|
|
2776
|
+
effortNow,
|
|
2777
|
+
effortRates: effortRates(events),
|
|
2778
|
+
effortWarning: effortWarning(events, effortNow, binding),
|
|
2779
|
+
codex: codexBlock(now),
|
|
2780
|
+
// True when the scan hit its time budget before it had read everything, so
|
|
2781
|
+
// the spend since the snapshot is a floor rather than a total. Anything
|
|
2782
|
+
// that warns on the number says "at least" when this is set.
|
|
2783
|
+
scanPartial,
|
|
2089
2784
|
});
|
|
2090
2785
|
}
|
|
2091
2786
|
|
|
@@ -2154,9 +2849,16 @@ function statusLine(collected) {
|
|
|
2154
2849
|
if (!snapshot || typeof snapshot.utilization !== 'number') continue;
|
|
2155
2850
|
const resetsAt = snapshot.resets_at ? Date.parse(snapshot.resets_at) : null;
|
|
2156
2851
|
const msToReset = Number.isFinite(resetsAt) ? resetsAt - now : null;
|
|
2852
|
+
// The snapshot is what the account last said; the spend since then is
|
|
2853
|
+
// measured by a transcript scan this line can never afford. When a hook has
|
|
2854
|
+
// already paid for that scan recently, use its answer. Measured on a real
|
|
2855
|
+
// session, the difference was 13 per cent here against 73 in the report,
|
|
2856
|
+
// and the flattering one was the one on screen.
|
|
2857
|
+
const corrected = reading.correctedFor(spec.key, now, collected.snapshotFetchedAt);
|
|
2157
2858
|
parts.push({
|
|
2158
2859
|
label: SHORT_LABELS[spec.key] || spec.label,
|
|
2159
|
-
percent: snapshot.utilization,
|
|
2860
|
+
percent: corrected ? corrected.percentUsed : snapshot.utilization,
|
|
2861
|
+
adjusted: Boolean(corrected && corrected.adjusted),
|
|
2160
2862
|
msToReset,
|
|
2161
2863
|
// A per-model weekly for a model that is not running is shown - hiding a
|
|
2162
2864
|
// limit outright is the one failure worse than over-reporting one, and
|
|
@@ -2264,7 +2966,11 @@ function render(data) {
|
|
|
2264
2966
|
// Two different situations, and telling them apart matters. Nothing to read
|
|
2265
2967
|
// is a setup problem. Nothing to report is the correct answer on a plan
|
|
2266
2968
|
// whose usage scales with credits rather than resetting on a clock.
|
|
2267
|
-
if (data.
|
|
2969
|
+
if (data.unreadable) {
|
|
2970
|
+
lines.push(' The meter reports usage windows but no readable percentage for them.');
|
|
2971
|
+
lines.push(' That is a reading problem, not flexible pricing: the limit still');
|
|
2972
|
+
lines.push(' applies. Run /status in Codex, or try again in a minute.');
|
|
2973
|
+
} else if (data.windowless) {
|
|
2268
2974
|
lines.push(' This account reports no rolling usage window.');
|
|
2269
2975
|
lines.push(' On flexible pricing there is no percentage to run down: usage scales');
|
|
2270
2976
|
lines.push(' with credits, so the credit balance above is the budget to plan against.');
|
|
@@ -2335,6 +3041,26 @@ function render(data) {
|
|
|
2335
3041
|
}
|
|
2336
3042
|
lines.push('');
|
|
2337
3043
|
|
|
3044
|
+
// The other agent, directly under this one's windows and counting the other
|
|
3045
|
+
// way: Codex reports what is LEFT, so every figure here says "left" and none
|
|
3046
|
+
// of them can be read as a percentage spent.
|
|
3047
|
+
if (data.codex && data.codex.rows.length) {
|
|
3048
|
+
lines.push(' Codex usage' + (data.codex.plan ? ' ' + data.codex.plan : ''));
|
|
3049
|
+
lines.push(' Window Left Resets in');
|
|
3050
|
+
for (const row of data.codex.rows) {
|
|
3051
|
+
lines.push(
|
|
3052
|
+
' ' + pad(row.title, 15) +
|
|
3053
|
+
padLeft(row.percentLeft === null ? (row.stale ? 'rolled' : '-') : row.percentLeft + '%', 6) +
|
|
3054
|
+
padLeft(row.stale ? '-' : formatDuration(row.msToReset), 12)
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
if (data.codex.note) lines.push(' ' + data.codex.note);
|
|
3058
|
+
else if (Number.isFinite(data.codex.ageMs)) {
|
|
3059
|
+
lines.push(' reading from ' + formatDuration(data.codex.ageMs) + ' ago, out of the rollouts Codex writes');
|
|
3060
|
+
}
|
|
3061
|
+
lines.push('');
|
|
3062
|
+
}
|
|
3063
|
+
|
|
2338
3064
|
// How much room is left is only half the question. The other half is what
|
|
2339
3065
|
// that room buys, and the answer is different for every model: the same
|
|
2340
3066
|
// weekly holds a few hundred Fable turns or several thousand Sonnet ones.
|
|
@@ -2476,6 +3202,48 @@ function render(data) {
|
|
|
2476
3202
|
(data.subagentTurns > 0 ? ' (+' + formatCount(data.subagentTurns) + ' subagent calls)' : '')
|
|
2477
3203
|
);
|
|
2478
3204
|
|
|
3205
|
+
// What a turn costs at each effort, when more than one has been measured.
|
|
3206
|
+
// The single most useful line for anyone wondering where the budget went:
|
|
3207
|
+
// effort changes the price of every turn rather than how many there are.
|
|
3208
|
+
const rates = (data.effortRates || []).filter((row) => row.turns >= MIN_EFFORT_SAMPLE && row.outputPerTurn);
|
|
3209
|
+
if (rates.length > 1) {
|
|
3210
|
+
lines.push('');
|
|
3211
|
+
lines.push(' What each effort costs, measured on this machine');
|
|
3212
|
+
lines.push(' Effort Turns Output/turn' + (money ? ' Per turn' : ''));
|
|
3213
|
+
for (const row of rates) {
|
|
3214
|
+
lines.push(
|
|
3215
|
+
' ' +
|
|
3216
|
+
String(row.effort + (row.effort === data.effortNow ? ' *' : '')).padEnd(12) +
|
|
3217
|
+
padLeft(formatCount(row.turns), 7) +
|
|
3218
|
+
padLeft(formatCount(Math.round(row.outputPerTurn)), 14) +
|
|
3219
|
+
(money ? padLeft(formatUSD(row.perTurn), 11) : '')
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3222
|
+
if (data.effortNow) lines.push(' * the effort set now');
|
|
3223
|
+
lines.push(' Output per turn is the part the effort setting controls; cost per turn');
|
|
3224
|
+
lines.push(' also moves with how big the context happened to be.');
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
if (data.effortWarning) {
|
|
3228
|
+
const warning = data.effortWarning;
|
|
3229
|
+
lines.push('');
|
|
3230
|
+
if (Number.isFinite(warning.turnsLeft)) {
|
|
3231
|
+
lines.push(
|
|
3232
|
+
' Careful at ' + warning.effort + ' effort this window holds about ' +
|
|
3233
|
+
formatCount(warning.turnsLeft) + ' more turns' +
|
|
3234
|
+
(Number.isFinite(warning.blendedTurnsLeft) && warning.blendedTurnsLeft > warning.turnsLeft
|
|
3235
|
+
? ', not the ' + formatCount(warning.blendedTurnsLeft) + ' above'
|
|
3236
|
+
: '')
|
|
3237
|
+
);
|
|
3238
|
+
}
|
|
3239
|
+
if (warning.cheaper && Number.isFinite(warning.cheaper.multiple)) {
|
|
3240
|
+
lines.push(
|
|
3241
|
+
' ' + warning.effort + ' writes about ' + warning.cheaper.multiple.toFixed(1) +
|
|
3242
|
+
'x the output per turn that ' + warning.cheaper.effort + ' does'
|
|
3243
|
+
);
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
|
|
2479
3247
|
if (data.windows.some((window) => window.adjusted)) {
|
|
2480
3248
|
lines.push(
|
|
2481
3249
|
' Note ~ includes spend since the snapshot was taken, which its own'
|
|
@@ -2850,7 +3618,20 @@ module.exports = {
|
|
|
2850
3618
|
preferLive,
|
|
2851
3619
|
accountUuid,
|
|
2852
3620
|
subagentTranscripts,
|
|
3621
|
+
claudeTranscriptFiles,
|
|
2853
3622
|
readClaudeEvents,
|
|
3623
|
+
SCAN_VERSION,
|
|
3624
|
+
SCAN_KEEP_MS,
|
|
3625
|
+
SCAN_MAX_EVENTS,
|
|
3626
|
+
sessionTranscriptFile,
|
|
3627
|
+
liveEffort,
|
|
3628
|
+
liveModel,
|
|
3629
|
+
EFFORT_TAIL_BYTES,
|
|
3630
|
+
scanFile,
|
|
3631
|
+
readScanCache,
|
|
3632
|
+
writeScanCache,
|
|
3633
|
+
writeJsonAtomic,
|
|
3634
|
+
parseSlice,
|
|
2854
3635
|
RATES,
|
|
2855
3636
|
WINDOWS,
|
|
2856
3637
|
rateFor,
|
|
@@ -2880,6 +3661,8 @@ module.exports = {
|
|
|
2880
3661
|
SATURATION_LIMIT,
|
|
2881
3662
|
MIN_BASELINE_TURNS,
|
|
2882
3663
|
MIN_BASELINE_PERCENT,
|
|
3664
|
+
usableCalibration,
|
|
3665
|
+
MAX_BASELINE_PERCENT,
|
|
2883
3666
|
accountFile,
|
|
2884
3667
|
buildWindows,
|
|
2885
3668
|
limitWindows,
|
|
@@ -2892,6 +3675,12 @@ module.exports = {
|
|
|
2892
3675
|
calibrationFile,
|
|
2893
3676
|
CRITICAL_PERCENT,
|
|
2894
3677
|
dominantEffort,
|
|
3678
|
+
effortRates,
|
|
3679
|
+
codexBlock,
|
|
3680
|
+
effortWarning,
|
|
3681
|
+
MIN_EFFORT_SAMPLE,
|
|
3682
|
+
EFFORT_DEARER_BY,
|
|
3683
|
+
FEW_TURNS_AT_EFFORT,
|
|
2895
3684
|
typicalTurnCost,
|
|
2896
3685
|
activeSessions,
|
|
2897
3686
|
sessionSpend,
|