claude-usage-limits 1.11.7 → 1.13.1
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 +50 -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 +260 -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 +127 -0
- package/skills/usage-limits/scripts/recommend.js +16 -3
- package/skills/usage-limits/scripts/relay.js +957 -0
- package/skills/usage-limits/scripts/statusline.js +8 -7
- package/skills/usage-limits/scripts/tally.js +7 -8
- package/skills/usage-limits/scripts/usage.js +855 -54
- 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 +316 -0
|
@@ -18,6 +18,9 @@ const host = require('./host.js');
|
|
|
18
18
|
const tally = require('./tally.js');
|
|
19
19
|
const activity = require('./activity.js');
|
|
20
20
|
const live = require('./live.js');
|
|
21
|
+
const relay = require('./relay.js');
|
|
22
|
+
const reading = require('./reading.js');
|
|
23
|
+
const voice = require('./voice.js');
|
|
21
24
|
|
|
22
25
|
const SECOND = 1000;
|
|
23
26
|
const DAY = 24 * 60 * 60 * 1000;
|
|
@@ -59,6 +62,17 @@ const DEFAULTS = {
|
|
|
59
62
|
// A hook has ten seconds; the reading gets four of them at most.
|
|
60
63
|
const REFRESH_TIMEOUT_MS = 4000;
|
|
61
64
|
|
|
65
|
+
// The hook is given ten seconds, and a live reading may take four of them, so
|
|
66
|
+
// the transcript scan gets five and the last second is slack. A warm scan
|
|
67
|
+
// takes about a quarter of a second; this is the guard for the first run on a
|
|
68
|
+
// machine with months of transcripts, where the alternative is the hook being
|
|
69
|
+
// killed and Claude being told nothing at all.
|
|
70
|
+
const SCAN_BUDGET_MS = 5000;
|
|
71
|
+
|
|
72
|
+
// How far into the hook arming may still start. See relayState: the task
|
|
73
|
+
// registration is about a second and the hook is allowed ten.
|
|
74
|
+
const ARM_DEADLINE_MS = 5000;
|
|
75
|
+
|
|
62
76
|
// Past this much of a per-model week, say how to free it. Below it the advice
|
|
63
77
|
// is noise: there is room, and the model in use is the right one.
|
|
64
78
|
const HALF_SPENT = 50;
|
|
@@ -177,12 +191,10 @@ function mergeCache(all, sessionId, entry, keep) {
|
|
|
177
191
|
}
|
|
178
192
|
|
|
179
193
|
function writeCache(all) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
|
|
185
|
-
}
|
|
194
|
+
// Never throws: a cache miss costs a scan, a crash costs the prompt. Atomic
|
|
195
|
+
// because every open window's prompt hook writes this same file, and a torn
|
|
196
|
+
// read by one of them wiped the others' slots on the way back.
|
|
197
|
+
usage.writeJsonAtomic(cacheFile(), all);
|
|
186
198
|
}
|
|
187
199
|
|
|
188
200
|
function settings() {
|
|
@@ -356,6 +368,41 @@ function count(value, word) {
|
|
|
356
368
|
return value + ' ' + word + (Math.abs(value) === 1 ? '' : 's');
|
|
357
369
|
}
|
|
358
370
|
|
|
371
|
+
// The other agent's meter, in one clause.
|
|
372
|
+
//
|
|
373
|
+
// Codex reports what is LEFT where Claude reports what is USED, so every
|
|
374
|
+
// figure here carries the word "left" - a bare percentage next to Claude's
|
|
375
|
+
// would be read as the same kind of number and mean the opposite thing.
|
|
376
|
+
//
|
|
377
|
+
// Read from Codex's rollouts on disk. Nothing spawns Codex, nothing waits on
|
|
378
|
+
// it, and a machine without it pays one stat call.
|
|
379
|
+
function codexSummary(now) {
|
|
380
|
+
try {
|
|
381
|
+
if (usage.isCodex() || !host.codexHasSessions()) return null;
|
|
382
|
+
const display = require('./view.js');
|
|
383
|
+
const codex = require('./codex.js');
|
|
384
|
+
const other = codex.collect(now);
|
|
385
|
+
const block = display.buildCodex({
|
|
386
|
+
now,
|
|
387
|
+
utilization: other.utilization,
|
|
388
|
+
fetchedAtMs: other.snapshotFetchedAt,
|
|
389
|
+
windowSpecs: other.windowSpecs,
|
|
390
|
+
plan: other.plan,
|
|
391
|
+
windowless: other.windowless,
|
|
392
|
+
});
|
|
393
|
+
if (!block.present) return null;
|
|
394
|
+
const names = { five_hour: '5-hour', seven_day: 'weekly' };
|
|
395
|
+
const bits = block.rows
|
|
396
|
+
.filter((row) => row.percentLeft !== null)
|
|
397
|
+
.map((row) => (names[row.key] || row.title) + ' ' + Math.floor(row.percentLeft) + '% left');
|
|
398
|
+
if (!bits.length) return null;
|
|
399
|
+
return { plan: block.plan, bits, stale: block.state !== 'live' };
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// An unreadable Codex is simply no Codex clause.
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
359
406
|
function describeWindow(window) {
|
|
360
407
|
if (!window) return null;
|
|
361
408
|
if (window.stale) return window.label + ' rolling over';
|
|
@@ -460,6 +507,51 @@ function briefText(parts) {
|
|
|
460
507
|
'the amount that ran out last time, not a fresh allowance.'
|
|
461
508
|
);
|
|
462
509
|
}
|
|
510
|
+
// The other agent, when there is one on this machine. Its own budget, its
|
|
511
|
+
// own direction: Codex counts down.
|
|
512
|
+
if (parts.codex && parts.codex.bits.length) {
|
|
513
|
+
sentences.push(
|
|
514
|
+
'Codex' + (parts.codex.plan ? ' (' + parts.codex.plan + ')' : '') + ' has ' +
|
|
515
|
+
parts.codex.bits.join(' and ') +
|
|
516
|
+
(parts.codex.stale ? ', from the last reading it wrote' : '') + '.'
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
// The effort setting changes the PRICE of a turn rather than how many there
|
|
520
|
+
// are, and the blended headroom above hides that completely: an account that
|
|
521
|
+
// has just moved to a dearer effort goes on being priced at the old one until
|
|
522
|
+
// enough dear turns have landed to drag the average up, and on a small window
|
|
523
|
+
// there is no "enough" - the window is gone first.
|
|
524
|
+
//
|
|
525
|
+
// A ChatGPT Plus account running gpt-6-astra at ultra effort emptied a whole
|
|
526
|
+
// five-hour window on one ordinary task while this hook reported room the
|
|
527
|
+
// entire way. Saying it here is what turns "plenty of room" into "about
|
|
528
|
+
// twenty turns" before the window is spent rather than after.
|
|
529
|
+
if (parts.effortWarning) {
|
|
530
|
+
const warning = parts.effortWarning;
|
|
531
|
+
const bits = [];
|
|
532
|
+
if (Number.isFinite(warning.turnsLeft)) {
|
|
533
|
+
const blended = warning.blendedTurnsLeft;
|
|
534
|
+
bits.push(
|
|
535
|
+
'at ' + warning.effort + ' effort this window holds about ' + count(warning.turnsLeft, 'turn') +
|
|
536
|
+
(Number.isFinite(blended) && blended > warning.turnsLeft
|
|
537
|
+
? ', not the ' + blended + ' the headroom above suggests'
|
|
538
|
+
: '')
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (warning.cheaper && Number.isFinite(warning.cheaper.multiple)) {
|
|
542
|
+
bits.push(
|
|
543
|
+
warning.effort + ' writes about ' + warning.cheaper.multiple.toFixed(1) +
|
|
544
|
+
' times the output per turn that ' + warning.cheaper.effort + ' does'
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (bits.length) {
|
|
548
|
+
sentences.push(
|
|
549
|
+
'The effort setting is what is spending this: ' + bits.join(', and ') +
|
|
550
|
+
'. Keep it where the work genuinely needs the thinking and drop it where it ' +
|
|
551
|
+
'does not; it changes what every turn costs, not how many you get.'
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
463
555
|
// A per-model weekly is the one window effort cannot help with. Nothing you
|
|
464
556
|
// do more cheaply on this model frees it; only running a different model
|
|
465
557
|
// does, and that has to be said, because the obvious move at 90 percent is
|
|
@@ -526,6 +618,47 @@ function briefText(parts) {
|
|
|
526
618
|
);
|
|
527
619
|
}
|
|
528
620
|
|
|
621
|
+
if (parts.voiceNote) sentences.push('How this user wants to be written to: ' + parts.voiceNote);
|
|
622
|
+
|
|
623
|
+
// What the relay changes about all of this.
|
|
624
|
+
//
|
|
625
|
+
// With a wake booked, the end of the window stops being the end of the work,
|
|
626
|
+
// and the instruction at the wall changes shape: the handoff is no longer a
|
|
627
|
+
// note somebody has to find and read, it is the prompt this conversation
|
|
628
|
+
// will be handed back. That is worth two minutes of writing, and it is worth
|
|
629
|
+
// saying plainly, because a session told only "you are about to be cut off"
|
|
630
|
+
// spends its last turns hedging.
|
|
631
|
+
const carry = parts.relay;
|
|
632
|
+
if (carry && carry.armed) {
|
|
633
|
+
const wake = new Date(carry.armed.wakeAt);
|
|
634
|
+
sentences.push(
|
|
635
|
+
'A relay is armed: ' + (carry.justArmed ? 'booked just now' : 'booked') + ' for ' +
|
|
636
|
+
wake.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ', ' +
|
|
637
|
+
carry.config.graceMinutes + ' minutes after this window resets, and it will ' +
|
|
638
|
+
(carry.armed.mode === 'resume' ? 'hand your continuation straight back to this conversation' : 'notify the user with your continuation ready to open') +
|
|
639
|
+
'.' + (carry.armed.warning ? ' Caveat: ' + carry.armed.warning + '.' : '')
|
|
640
|
+
);
|
|
641
|
+
if (!carry.armed.continuation) {
|
|
642
|
+
sentences.push(
|
|
643
|
+
'Nothing has been written for it yet. Before this session ends, run ' +
|
|
644
|
+
'node "$CLAUDE_PLUGIN_ROOT/skills/usage-limits/scripts/relay.js" note "<what you would tell yourself>" ' +
|
|
645
|
+
'with what is done, what is next in order, which files are mid-change and what must be verified first. ' +
|
|
646
|
+
'That text is the prompt the relay delivers, so write it to be acted on, not read.'
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (carry && carry.last) {
|
|
651
|
+
sentences.push(
|
|
652
|
+
'The last relay ' +
|
|
653
|
+
(carry.last.outcome === 'resumed'
|
|
654
|
+
? 'picked this work back up automatically after the previous reset (' + (carry.last.detail || 'resumed') + '); check what it did before repeating it'
|
|
655
|
+
: carry.last.outcome === 'notified'
|
|
656
|
+
? 'left a notification after the previous reset rather than starting anything'
|
|
657
|
+
: 'ended as "' + carry.last.outcome + '"' + (carry.last.detail ? ' - ' + carry.last.detail : '')) +
|
|
658
|
+
'.'
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
529
662
|
// Three states, and only the last one stops anything.
|
|
530
663
|
//
|
|
531
664
|
// The middle one is the one that keeps being got wrong. Near the wall the
|
|
@@ -538,7 +671,19 @@ function briefText(parts) {
|
|
|
538
671
|
parts.pressure === 'gone'
|
|
539
672
|
? 'The budget is gone, so nothing further will run. Save what exists and ' +
|
|
540
673
|
'write the handoff: what is finished, what is next and in what order, ' +
|
|
541
|
-
'which files are mid-change, and when the window resets.'
|
|
674
|
+
'which files are mid-change, and when the window resets.' +
|
|
675
|
+
(parts.relay && parts.relay.armed
|
|
676
|
+
? ' Write it into the relay note as well as into your reply - that is ' +
|
|
677
|
+
'the copy that gets acted on when the window reopens.'
|
|
678
|
+
: '')
|
|
679
|
+
: parts.pressure === 'tight' && parts.relay && parts.relay.armed
|
|
680
|
+
? 'The budget is nearly gone and the relay has it: being cut off now ' +
|
|
681
|
+
'costs the wait, not the work. So do not wind down, do not narrow the ' +
|
|
682
|
+
'request, and do not stop to ask whether to go on. Carry on at full ' +
|
|
683
|
+
'quality, save at clean boundaries, and put everything the next turn ' +
|
|
684
|
+
'needs into the relay note rather than into a summary for a person to ' +
|
|
685
|
+
'read. Say in one line what will land after the reset instead of ' +
|
|
686
|
+
'before it, then keep working until the window actually ends.'
|
|
542
687
|
: parts.pressure === 'tight'
|
|
543
688
|
? 'The budget is nearly gone, so make being cut off cheap rather than ' +
|
|
544
689
|
'doing less. Carry on with the whole request at full quality: this is ' +
|
|
@@ -631,6 +776,65 @@ function tallyContext(all, sessionId, now) {
|
|
|
631
776
|
};
|
|
632
777
|
}
|
|
633
778
|
|
|
779
|
+
// The relay, decided once per prompt.
|
|
780
|
+
//
|
|
781
|
+
// Two things happen here and neither of them slows the work down. Above the
|
|
782
|
+
// arming threshold, with a plan or an unfinished todo list to carry, a one-shot
|
|
783
|
+
// wake is booked for a few minutes after the reset - a scheduled task costs
|
|
784
|
+
// nothing and changes nothing about the turn in progress. And once a wake
|
|
785
|
+
// exists, what the hook tells Claude at the wall changes: the handoff is no
|
|
786
|
+
// longer a note for a person to find, it is the thing that will be handed back
|
|
787
|
+
// automatically, so it is worth writing properly.
|
|
788
|
+
//
|
|
789
|
+
// It also reports what happened last time. A relay that fired while nobody was
|
|
790
|
+
// watching is exactly the sort of thing a session should not have to be asked
|
|
791
|
+
// about.
|
|
792
|
+
function relayState(now, hookInput, binding, sessionId) {
|
|
793
|
+
try {
|
|
794
|
+
const state = relay.read();
|
|
795
|
+
const config = relay.settings(state);
|
|
796
|
+
const last = state.history[state.history.length - 1];
|
|
797
|
+
const recent = last && Number.isFinite(last.endedAt) && now - last.endedAt < 6 * 60 * 60 * 1000 ? last : null;
|
|
798
|
+
if (!config.enabled) return recent ? { enabled: false, last: recent } : null;
|
|
799
|
+
|
|
800
|
+
// Already armed for this session: nothing to decide, just say so.
|
|
801
|
+
if (state.armed && state.armed.id === sessionId) {
|
|
802
|
+
return { enabled: true, armed: state.armed, config, last: recent };
|
|
803
|
+
}
|
|
804
|
+
const work = relay.detectWork(hookInput && hookInput.transcript_path, {});
|
|
805
|
+
const able = relay.armable({ config, binding, sessionId, work });
|
|
806
|
+
if (!able.ok) return { enabled: true, why: able.why, config, last: recent, work };
|
|
807
|
+
// Registering a scheduled task measured 936 ms, and this hook has ten
|
|
808
|
+
// seconds of which the live reading may take four and the scan five. A
|
|
809
|
+
// hook that is killed tells Claude nothing at all, which is far worse than
|
|
810
|
+
// a relay that arms on the next prompt instead of this one - and if there
|
|
811
|
+
// is no next prompt, the session ended and there was nothing to carry.
|
|
812
|
+
if (Date.now() - now > ARM_DEADLINE_MS) {
|
|
813
|
+
return { enabled: true, why: 'no time left in this hook; arming on the next prompt', config, last: recent, work };
|
|
814
|
+
}
|
|
815
|
+
const armed = relay.arm({
|
|
816
|
+
now,
|
|
817
|
+
config,
|
|
818
|
+
// Registration must be finished, not merely started, inside the hook's
|
|
819
|
+
// ten seconds; the scheduler trims its own waits to this.
|
|
820
|
+
deadline: now + ARM_DEADLINE_MS + 4000,
|
|
821
|
+
sessionId,
|
|
822
|
+
binding,
|
|
823
|
+
work,
|
|
824
|
+
resetsAt: binding.resetsAt,
|
|
825
|
+
cwd: (hookInput && hookInput.cwd) || process.cwd(),
|
|
826
|
+
project: path.basename((hookInput && hookInput.cwd) || process.cwd()),
|
|
827
|
+
hostName: usage.currentHost(),
|
|
828
|
+
});
|
|
829
|
+
return armed.ok
|
|
830
|
+
? { enabled: true, armed: armed.record, justArmed: true, config, last: recent, work }
|
|
831
|
+
: { enabled: true, error: armed.error, config, last: recent, work };
|
|
832
|
+
} catch (err) {
|
|
833
|
+
// Nothing about carrying work forward is worth breaking the prompt for.
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
634
838
|
async function run(now, hookInput) {
|
|
635
839
|
if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
|
|
636
840
|
|
|
@@ -645,15 +849,22 @@ async function run(now, hookInput) {
|
|
|
645
849
|
activity.mark(
|
|
646
850
|
'working',
|
|
647
851
|
sessionId,
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
852
|
+
Object.assign(
|
|
853
|
+
{
|
|
854
|
+
// Whole words only: both are real directives in the prompt. Ultrathink
|
|
855
|
+
// is per prompt, so it is set true or false every time.
|
|
856
|
+
ultrathink: Boolean(
|
|
857
|
+
hookInput && typeof hookInput.prompt === 'string' && /\bultrathink\b/i.test(hookInput.prompt)
|
|
858
|
+
),
|
|
859
|
+
},
|
|
860
|
+
// Ultracode is Claude Code's own keyword trigger and it sticks for the
|
|
861
|
+
// session once used, so it is only ever set on here and otherwise
|
|
862
|
+
// carried forward. It is safe to read from the text now that every
|
|
863
|
+
// display reads the mark of the one session it describes.
|
|
864
|
+
hookInput && typeof hookInput.prompt === 'string' && /\bultracode\b/i.test(hookInput.prompt)
|
|
865
|
+
? { ultracode: true }
|
|
866
|
+
: {}
|
|
867
|
+
),
|
|
657
868
|
now
|
|
658
869
|
);
|
|
659
870
|
|
|
@@ -699,7 +910,7 @@ async function run(now, hookInput) {
|
|
|
699
910
|
if (!view || !view.binding) {
|
|
700
911
|
// One call, shared with the report. Building the view twice is how the
|
|
701
912
|
// snapshot correction reached the report and never reached the hook.
|
|
702
|
-
const data = await usage.report(now, { sessionId });
|
|
913
|
+
const data = await usage.report(now, { sessionId, budgetMs: SCAN_BUDGET_MS });
|
|
703
914
|
const binding = data.binding;
|
|
704
915
|
view = {
|
|
705
916
|
at: now,
|
|
@@ -716,6 +927,7 @@ async function run(now, hookInput) {
|
|
|
716
927
|
})),
|
|
717
928
|
snapshotAge: usage.formatDuration(data.snapshotAgeMs),
|
|
718
929
|
binding: cacheableBinding(binding),
|
|
930
|
+
effortWarning: data.effortWarning || null,
|
|
719
931
|
};
|
|
720
932
|
writeCache(mergeCache(all, sessionId, view, KEEP_SESSIONS));
|
|
721
933
|
}
|
|
@@ -732,7 +944,33 @@ async function run(now, hookInput) {
|
|
|
732
944
|
binding.headroomMs <= RUNWAY_MENTION_MS;
|
|
733
945
|
// Outside the cache on purpose: the tally moves after every reply.
|
|
734
946
|
const found = tallyContext(tally.readState(), sessionId, now);
|
|
947
|
+
// Learning how the user writes, from the prompt that just arrived. Counters
|
|
948
|
+
// only, no model call, and it never speaks: what it knows is read back by
|
|
949
|
+
// /usage-limits:voice and used when the relay writes as them.
|
|
950
|
+
try {
|
|
951
|
+
if (hookInput && typeof hookInput.prompt === 'string') voice.observe(hookInput.prompt, now);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
// Style is not worth a failed hook.
|
|
954
|
+
}
|
|
955
|
+
// This hook has just paid for a transcript scan, so the corrected figure is
|
|
956
|
+
// in hand. Leave it where the status line can read it: that line redraws far
|
|
957
|
+
// too often to scan for itself, and without this it shows the raw snapshot,
|
|
958
|
+
// which during a heavy session is wrong by tens of points in the flattering
|
|
959
|
+
// direction.
|
|
960
|
+
reading.record(binding, now, usage.isCodex() ? require('./codex.js').homeDir() : null);
|
|
961
|
+
const carry = relayState(now, hookInput, binding, sessionId);
|
|
962
|
+
// An instruction the user typed at /usage-limits:voice set. The learned
|
|
963
|
+
// traits are for writing AS them and stay out of the way; this is them
|
|
964
|
+
// saying how they want to be talked to, so it is said every time.
|
|
965
|
+
let voiceNote = null;
|
|
966
|
+
try {
|
|
967
|
+
voiceNote = voice.read().note;
|
|
968
|
+
} catch (err) {
|
|
969
|
+
voiceNote = null;
|
|
970
|
+
}
|
|
735
971
|
return briefText({
|
|
972
|
+
relay: carry,
|
|
973
|
+
voiceNote,
|
|
736
974
|
lastReply: found.lastReply,
|
|
737
975
|
context: found.context,
|
|
738
976
|
lastSession: found.lastSession,
|
|
@@ -754,6 +992,10 @@ async function run(now, hookInput) {
|
|
|
754
992
|
: null,
|
|
755
993
|
othersSummary: view.othersSummary,
|
|
756
994
|
turnsLeft: view.turnsLeft,
|
|
995
|
+
effortWarning: view.effortWarning || null,
|
|
996
|
+
// Outside the cache: it is cheap, and it belongs to the other agent's
|
|
997
|
+
// clock rather than this session's.
|
|
998
|
+
codex: codexSummary(now),
|
|
757
999
|
resetsIn:
|
|
758
1000
|
binding && !binding.stale && Number.isFinite(binding.resetsAt)
|
|
759
1001
|
? usage.formatDuration(binding.resetsAt - now)
|
|
@@ -803,6 +1045,7 @@ module.exports = {
|
|
|
803
1045
|
describeWindow,
|
|
804
1046
|
summariseOthers,
|
|
805
1047
|
briefText,
|
|
1048
|
+
codexSummary,
|
|
806
1049
|
tallyContext,
|
|
807
1050
|
LARGE_CONTEXT_TOKENS,
|
|
808
1051
|
settings,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Reversible edits to Codex's top-level defaults. Never changes a running turn.
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const KEYS = ['model', 'model_reasoning_effort'];
|
|
7
|
+
const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
|
|
8
|
+
|
|
9
|
+
function scan(text) {
|
|
10
|
+
// Do not pretend a line editor is a full TOML parser.
|
|
11
|
+
if (text.includes('"""') || text.includes("'''")) throw new Error('Multiline TOML requires manual editing; config was not changed.');
|
|
12
|
+
const lines = text.match(/[^\n]*\n|[^\n]+$/g) || [];
|
|
13
|
+
const found = {};
|
|
14
|
+
for (let i = 0; i < lines.length; i++) {
|
|
15
|
+
const line = lines[i].replace(/^\uFEFF/, '');
|
|
16
|
+
if (/^\s*\[/.test(line)) break;
|
|
17
|
+
for (const key of KEYS) {
|
|
18
|
+
const re = new RegExp('^\\s*(?:' + key + '|"' + key + '"|\\x27' + key + '\\x27)\\s*=');
|
|
19
|
+
if (!re.test(line)) continue;
|
|
20
|
+
if (Object.hasOwn(found, key)) throw new Error('Duplicate top-level ' + key + '; config was not changed.');
|
|
21
|
+
if (!/=\s*(?:"(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?(?:\r?\n)?$/.test(line))
|
|
22
|
+
throw new Error('Unsupported value for ' + key + '; config was not changed.');
|
|
23
|
+
found[key] = { index: i, line: lines[i] };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { lines, found };
|
|
27
|
+
}
|
|
28
|
+
function rewrite(text, values) {
|
|
29
|
+
const { lines, found } = scan(text);
|
|
30
|
+
const prepend = [];
|
|
31
|
+
for (const [key, line] of Object.entries(values)) {
|
|
32
|
+
if (!KEYS.includes(key)) throw new Error('Invalid managed setting');
|
|
33
|
+
if (found[key]) lines[found[key].index] = line || '';
|
|
34
|
+
else if (line) prepend.push(line);
|
|
35
|
+
}
|
|
36
|
+
return prepend.join('') + lines.join('');
|
|
37
|
+
}
|
|
38
|
+
function read(file, fallback) {
|
|
39
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return fallback; throw e; }
|
|
40
|
+
}
|
|
41
|
+
function atomic(file, text) {
|
|
42
|
+
const temp = file + '.' + process.pid + '.tmp';
|
|
43
|
+
try { fs.writeFileSync(temp, text, { encoding: 'utf8', mode: 0o600 }); fs.renameSync(temp, file); }
|
|
44
|
+
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
|
|
45
|
+
}
|
|
46
|
+
function checkState(state) {
|
|
47
|
+
if (!state) return;
|
|
48
|
+
if (state.version !== 1 || !state.previous || !state.applied) throw new Error('Unrecognized restore state; config was not changed.');
|
|
49
|
+
const keys = Object.keys(state.previous);
|
|
50
|
+
if (!keys.length || keys.some(k => !KEYS.includes(k)) || JSON.stringify(keys.sort()) !== JSON.stringify(Object.keys(state.applied).sort()))
|
|
51
|
+
throw new Error('Invalid restore state; config was not changed.');
|
|
52
|
+
for (const values of [state.previous, state.applied]) {
|
|
53
|
+
for (const [key, line] of Object.entries(values)) {
|
|
54
|
+
if (line === null) continue;
|
|
55
|
+
if (typeof line !== 'string') throw new Error('Invalid restore line');
|
|
56
|
+
const parsed = scan(line);
|
|
57
|
+
if (parsed.lines.length !== 1 || !parsed.found[key]) throw new Error('Invalid restore line');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function plan(text, options, state) {
|
|
62
|
+
checkState(state);
|
|
63
|
+
const { found } = scan(text);
|
|
64
|
+
const lineOf = key => found[key] ? found[key].line : null;
|
|
65
|
+
if (state) for (const [key, line] of Object.entries(state.applied)) {
|
|
66
|
+
if (lineOf(key) !== line) throw new Error(key + ' changed outside lowpower; review config and restore state before continuing.');
|
|
67
|
+
}
|
|
68
|
+
if (options.command === 'off') return { text: state ? rewrite(text, state.previous) : text, state: null };
|
|
69
|
+
const effort = options.effort || 'low';
|
|
70
|
+
if (!EFFORTS.includes(effort)) throw new Error('Unknown Codex effort: ' + effort);
|
|
71
|
+
if (options.model !== null && options.model !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(options.model))
|
|
72
|
+
throw new Error('Invalid Codex model identifier');
|
|
73
|
+
const wanted = { model_reasoning_effort: effort };
|
|
74
|
+
if (options.model) wanted.model = options.model;
|
|
75
|
+
const previous = { ...(state && state.previous) };
|
|
76
|
+
const applied = { ...(state && state.applied) };
|
|
77
|
+
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
|
78
|
+
for (const [key, value] of Object.entries(wanted)) {
|
|
79
|
+
if (!Object.hasOwn(previous, key)) previous[key] = lineOf(key);
|
|
80
|
+
applied[key] = key + ' = ' + JSON.stringify(value) + newline;
|
|
81
|
+
}
|
|
82
|
+
return { text: rewrite(text, applied), state: { version: 1, previous, applied } };
|
|
83
|
+
}
|
|
84
|
+
function main(args) {
|
|
85
|
+
if (!['status', 'on', 'off'].includes(args.command)) throw new Error('Expected status, on or off');
|
|
86
|
+
const dir = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
87
|
+
const file = path.join(dir, 'config.toml');
|
|
88
|
+
const stateFile = path.join(dir, 'usage-limits-lowpower.json');
|
|
89
|
+
const notice = 'Codex defaults only: new sessions. Active tasks, profiles and command-line overrides keep their own settings.';
|
|
90
|
+
// A lock serializes our writers. Other editors are detected before commit.
|
|
91
|
+
let lock;
|
|
92
|
+
if (args.command !== 'status' && !args.dryRun) {
|
|
93
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
94
|
+
lock = fs.openSync(stateFile + '.lock', 'wx');
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const text = read(file, '');
|
|
98
|
+
const rawState = read(stateFile, null);
|
|
99
|
+
const state = rawState ? JSON.parse(rawState) : null;
|
|
100
|
+
checkState(state);
|
|
101
|
+
if (args.command === 'status') {
|
|
102
|
+
const { found } = scan(text);
|
|
103
|
+
console.log('Codex low power: ' + (state ? 'on' : 'off'));
|
|
104
|
+
for (const key of KEYS) console.log(found[key] ? found[key].line.trim() : key + ' = (unset)');
|
|
105
|
+
console.log(notice);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const result = plan(text, args, state);
|
|
109
|
+
if (args.dryRun) {
|
|
110
|
+
console.log('Dry run; no files changed.\n' + notice);
|
|
111
|
+
const { found } = scan(result.text);
|
|
112
|
+
for (const key of KEYS) console.log(found[key] ? found[key].line.trim() : key + ' = (unset)');
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (read(file, '') !== text || read(stateFile, null) !== rawState) throw new Error('Settings changed concurrently; retry.');
|
|
116
|
+
// Save recovery information before changing defaults; roll it back on error.
|
|
117
|
+
if (result.state) atomic(stateFile, JSON.stringify(result.state, null, 2) + '\n');
|
|
118
|
+
try {
|
|
119
|
+
if (result.text !== text) {
|
|
120
|
+
if (!state && fs.existsSync(file)) fs.copyFileSync(file, file + '.usage-limits-backup');
|
|
121
|
+
atomic(file, result.text);
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (rawState !== null) atomic(stateFile, rawState);
|
|
125
|
+
else if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
if (!result.state && fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
|
|
129
|
+
console.log('Codex low power ' + args.command + '.\n' + notice);
|
|
130
|
+
return 0;
|
|
131
|
+
} finally {
|
|
132
|
+
if (lock !== undefined) { fs.closeSync(lock); fs.unlinkSync(stateFile + '.lock'); }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
module.exports = { scan, rewrite, plan, main, EFFORTS };
|
|
@@ -443,7 +443,10 @@ function readingsOf(meter) {
|
|
|
443
443
|
for (const entry of SLOTS) {
|
|
444
444
|
const window = meter[entry.slot];
|
|
445
445
|
if (!window || typeof window !== 'object') continue;
|
|
446
|
-
|
|
446
|
+
// Number(null) is 0, and a null percentage read as "0% used" is worse
|
|
447
|
+
// than no reading at all. Only an actual number counts.
|
|
448
|
+
const raw = window.used_percent;
|
|
449
|
+
const percent = raw === null || raw === undefined || raw === '' ? NaN : Number(raw);
|
|
447
450
|
if (!Number.isFinite(percent)) continue;
|
|
448
451
|
|
|
449
452
|
const minutes = Number(window.window_minutes);
|
|
@@ -482,9 +485,21 @@ function utilizationFrom(meter) {
|
|
|
482
485
|
// account has no rolling limit and usage scales with credits. Returning null
|
|
483
486
|
// here would throw away the plan and the credit balance, which on such an
|
|
484
487
|
// account are the only figures there are.
|
|
488
|
+
//
|
|
489
|
+
// A slot that IS there but carries no readable percentage is a different
|
|
490
|
+
// thing again: the meter answered without numbers, which happens around a
|
|
491
|
+
// limit hit. Calling that flexible pricing told Codex on 2026-09-07 that its
|
|
492
|
+
// Plus account had no window to run down, minutes after the window ran out.
|
|
493
|
+
const slotsPresent = SLOTS.filter((entry) => meter[entry.slot] && typeof meter[entry.slot] === 'object').length;
|
|
494
|
+
// Consumer plans are metered by windows without exception; only the
|
|
495
|
+
// business-side plans can be on flexible pricing. So on Plus, Pro, Go or
|
|
496
|
+
// Free a meter with no windows in it is a meter that failed to read them.
|
|
497
|
+
const planType = typeof meter.plan_type === 'string' ? meter.plan_type : '';
|
|
498
|
+
const consumer = /^(free|go|plus|pro|prolite)$/i.test(planType);
|
|
485
499
|
const credits = meter.credits && typeof meter.credits === 'object' ? meter.credits : null;
|
|
486
500
|
return {
|
|
487
|
-
windowless: specs.length === 0,
|
|
501
|
+
windowless: specs.length === 0 && slotsPresent === 0 && !consumer,
|
|
502
|
+
unreadable: specs.length === 0 && (slotsPresent > 0 || consumer),
|
|
488
503
|
utilization: specs.length ? utilization : null,
|
|
489
504
|
specs,
|
|
490
505
|
planType: typeof meter.plan_type === 'string' ? meter.plan_type : null,
|
|
@@ -599,31 +614,81 @@ function latestMeter(events) {
|
|
|
599
614
|
return null;
|
|
600
615
|
}
|
|
601
616
|
|
|
617
|
+
// The meter is written next to every request, so the newest one is always near
|
|
618
|
+
// the END of a rollout. Only the tail is read.
|
|
619
|
+
//
|
|
620
|
+
// This used to read whole files, and a rollout on this machine reaches 32 MB;
|
|
621
|
+
// twelve of those is a third of a gigabyte pulled through a string on a path
|
|
622
|
+
// that the status line takes every few hundred milliseconds. A megabyte of
|
|
623
|
+
// tail holds hundreds of token_count lines, which is far more than enough.
|
|
624
|
+
const TAIL_BYTES = 1024 * 1024;
|
|
625
|
+
|
|
626
|
+
function readTail(file, bytes) {
|
|
627
|
+
let size = 0;
|
|
628
|
+
try {
|
|
629
|
+
size = fs.statSync(file).size;
|
|
630
|
+
} catch (err) {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
const from = Math.max(0, size - bytes);
|
|
634
|
+
let fd;
|
|
635
|
+
try {
|
|
636
|
+
fd = fs.openSync(file, 'r');
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
const length = size - from;
|
|
642
|
+
if (length <= 0) return { text: '', partial: false };
|
|
643
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
644
|
+
let read = 0;
|
|
645
|
+
while (read < length) {
|
|
646
|
+
const got = fs.readSync(fd, buffer, read, length - read, from + read);
|
|
647
|
+
if (got <= 0) break;
|
|
648
|
+
read += got;
|
|
649
|
+
}
|
|
650
|
+
return { text: buffer.toString('utf8', 0, read), partial: from > 0 };
|
|
651
|
+
} catch (err) {
|
|
652
|
+
return null;
|
|
653
|
+
} finally {
|
|
654
|
+
try {
|
|
655
|
+
fs.closeSync(fd);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
// Already closed.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function meterFromLines(text, partial) {
|
|
663
|
+
const lines = text.split('\n');
|
|
664
|
+
// The first line of a tail is a fragment of whatever it landed in the middle
|
|
665
|
+
// of, so it is never parsed.
|
|
666
|
+
const floor = partial ? 1 : 0;
|
|
667
|
+
for (let index = lines.length - 1; index >= floor; index -= 1) {
|
|
668
|
+
const line = lines[index];
|
|
669
|
+
if (!line || line.indexOf('"token_count"') === -1) continue;
|
|
670
|
+
let parsed;
|
|
671
|
+
try {
|
|
672
|
+
parsed = JSON.parse(line);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const meter = parsed && parsed.payload && parsed.payload.rate_limits;
|
|
677
|
+
const at = Date.parse(parsed && parsed.timestamp);
|
|
678
|
+
if (meter && Number.isFinite(at)) return { meter, at };
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
602
683
|
// Scanning only the newest few rollouts, for the meter alone. `collect` runs on
|
|
603
684
|
// the status-line path where a full scan would be far too slow.
|
|
604
685
|
function meterFromDisk() {
|
|
605
686
|
const files = rolloutFiles(NaN).slice(-12).reverse();
|
|
606
687
|
for (const entry of files) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
const lines = raw.split('\n');
|
|
614
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
615
|
-
const line = lines[index];
|
|
616
|
-
if (!line || line.indexOf('"token_count"') === -1) continue;
|
|
617
|
-
let parsed;
|
|
618
|
-
try {
|
|
619
|
-
parsed = JSON.parse(line);
|
|
620
|
-
} catch (err) {
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
const meter = parsed && parsed.payload && parsed.payload.rate_limits;
|
|
624
|
-
const at = Date.parse(parsed && parsed.timestamp);
|
|
625
|
-
if (meter && Number.isFinite(at)) return { meter, at };
|
|
626
|
-
}
|
|
688
|
+
const tail = readTail(entry.file, TAIL_BYTES);
|
|
689
|
+
if (!tail) continue;
|
|
690
|
+
const found = meterFromLines(tail.text, tail.partial);
|
|
691
|
+
if (found) return found;
|
|
627
692
|
}
|
|
628
693
|
return null;
|
|
629
694
|
}
|
|
@@ -752,6 +817,8 @@ function collect(now, options) {
|
|
|
752
817
|
// what flexible pricing looks like. That is a different thing from having
|
|
753
818
|
// found nothing to read, and it needs to be said differently.
|
|
754
819
|
windowless: Boolean(mapped && mapped.windowless),
|
|
820
|
+
// The meter reported window slots but no readable percentage in them.
|
|
821
|
+
unreadable: Boolean(mapped && mapped.unreadable),
|
|
755
822
|
windowSpecs: mapped ? mapped.specs : null,
|
|
756
823
|
reachedType: mapped ? mapped.reachedType : null,
|
|
757
824
|
spendControlReached: Boolean(mapped && mapped.spendControlReached),
|
|
@@ -948,6 +1015,9 @@ module.exports = {
|
|
|
948
1015
|
planFrom,
|
|
949
1016
|
latestMeter,
|
|
950
1017
|
meterFromDisk,
|
|
1018
|
+
readTail,
|
|
1019
|
+
meterFromLines,
|
|
1020
|
+
TAIL_BYTES,
|
|
951
1021
|
liveFile,
|
|
952
1022
|
attemptFile,
|
|
953
1023
|
readLiveMeter,
|