claude-usage-limits 1.11.6 → 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 +288 -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
|
@@ -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,27 @@ 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
|
+
|
|
76
|
+
// Past this much of a per-model week, say how to free it. Below it the advice
|
|
77
|
+
// is noise: there is room, and the model in use is the right one.
|
|
78
|
+
const HALF_SPENT = 50;
|
|
79
|
+
|
|
80
|
+
// "fable" -> "Fable", the way the account names the window.
|
|
81
|
+
function familyLabel(family) {
|
|
82
|
+
const name = String(family || '');
|
|
83
|
+
return name ? name.charAt(0).toUpperCase() + name.slice(1) : name;
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
// The runway is worth saying long before it is worth acting on, because it is
|
|
63
87
|
// the figure that stops a turn count from flattering. Two hundred turns sounds
|
|
64
88
|
// like plenty and can be twenty minutes when three sessions are spending.
|
|
@@ -167,12 +191,10 @@ function mergeCache(all, sessionId, entry, keep) {
|
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
function writeCache(all) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
|
|
175
|
-
}
|
|
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);
|
|
176
198
|
}
|
|
177
199
|
|
|
178
200
|
function settings() {
|
|
@@ -269,6 +291,10 @@ function pressure(window, now, config, turnsLeft) {
|
|
|
269
291
|
const CACHED_BINDING_FIELDS = [
|
|
270
292
|
'key',
|
|
271
293
|
'label',
|
|
294
|
+
// The model family a per-model weekly is scoped to. Read by the line that
|
|
295
|
+
// says a model switch is the only thing that frees such a window, so it has
|
|
296
|
+
// to survive the cache like every other field the wording depends on.
|
|
297
|
+
'family',
|
|
272
298
|
'applies',
|
|
273
299
|
'percentUsed',
|
|
274
300
|
'stale',
|
|
@@ -342,6 +368,41 @@ function count(value, word) {
|
|
|
342
368
|
return value + ' ' + word + (Math.abs(value) === 1 ? '' : 's');
|
|
343
369
|
}
|
|
344
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
|
+
|
|
345
406
|
function describeWindow(window) {
|
|
346
407
|
if (!window) return null;
|
|
347
408
|
if (window.stale) return window.label + ' rolling over';
|
|
@@ -446,6 +507,62 @@ function briefText(parts) {
|
|
|
446
507
|
'the amount that ran out last time, not a fresh allowance.'
|
|
447
508
|
);
|
|
448
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
|
+
}
|
|
555
|
+
// A per-model weekly is the one window effort cannot help with. Nothing you
|
|
556
|
+
// do more cheaply on this model frees it; only running a different model
|
|
557
|
+
// does, and that has to be said, because the obvious move at 90 percent is
|
|
558
|
+
// to drop the effort and keep going, which spends the same window slower.
|
|
559
|
+
if (parts.family) {
|
|
560
|
+
sentences.push(
|
|
561
|
+
'That window counts ' + parts.family + ' turns only, so lowering effort does not free it: ' +
|
|
562
|
+
'switching model does. Use /model (or scripts/lowpower.js on --model <other>) for work ' +
|
|
563
|
+
'that does not need ' + parts.family + ', and keep this one for what does.'
|
|
564
|
+
);
|
|
565
|
+
}
|
|
449
566
|
if (parts.othersSummary) sentences.push('Other windows: ' + parts.othersSummary + '.');
|
|
450
567
|
|
|
451
568
|
// A window that is not binding can still be the expensive one to exhaust.
|
|
@@ -501,6 +618,47 @@ function briefText(parts) {
|
|
|
501
618
|
);
|
|
502
619
|
}
|
|
503
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
|
+
|
|
504
662
|
// Three states, and only the last one stops anything.
|
|
505
663
|
//
|
|
506
664
|
// The middle one is the one that keeps being got wrong. Near the wall the
|
|
@@ -513,7 +671,19 @@ function briefText(parts) {
|
|
|
513
671
|
parts.pressure === 'gone'
|
|
514
672
|
? 'The budget is gone, so nothing further will run. Save what exists and ' +
|
|
515
673
|
'write the handoff: what is finished, what is next and in what order, ' +
|
|
516
|
-
'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.'
|
|
517
687
|
: parts.pressure === 'tight'
|
|
518
688
|
? 'The budget is nearly gone, so make being cut off cheap rather than ' +
|
|
519
689
|
'doing less. Carry on with the whole request at full quality: this is ' +
|
|
@@ -606,6 +776,62 @@ function tallyContext(all, sessionId, now) {
|
|
|
606
776
|
};
|
|
607
777
|
}
|
|
608
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
|
+
sessionId,
|
|
819
|
+
binding,
|
|
820
|
+
work,
|
|
821
|
+
resetsAt: binding.resetsAt,
|
|
822
|
+
cwd: (hookInput && hookInput.cwd) || process.cwd(),
|
|
823
|
+
project: path.basename((hookInput && hookInput.cwd) || process.cwd()),
|
|
824
|
+
hostName: usage.currentHost(),
|
|
825
|
+
});
|
|
826
|
+
return armed.ok
|
|
827
|
+
? { enabled: true, armed: armed.record, justArmed: true, config, last: recent, work }
|
|
828
|
+
: { enabled: true, error: armed.error, config, last: recent, work };
|
|
829
|
+
} catch (err) {
|
|
830
|
+
// Nothing about carrying work forward is worth breaking the prompt for.
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
609
835
|
async function run(now, hookInput) {
|
|
610
836
|
if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
|
|
611
837
|
|
|
@@ -620,15 +846,22 @@ async function run(now, hookInput) {
|
|
|
620
846
|
activity.mark(
|
|
621
847
|
'working',
|
|
622
848
|
sessionId,
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
849
|
+
Object.assign(
|
|
850
|
+
{
|
|
851
|
+
// Whole words only: both are real directives in the prompt. Ultrathink
|
|
852
|
+
// is per prompt, so it is set true or false every time.
|
|
853
|
+
ultrathink: Boolean(
|
|
854
|
+
hookInput && typeof hookInput.prompt === 'string' && /\bultrathink\b/i.test(hookInput.prompt)
|
|
855
|
+
),
|
|
856
|
+
},
|
|
857
|
+
// Ultracode is Claude Code's own keyword trigger and it sticks for the
|
|
858
|
+
// session once used, so it is only ever set on here and otherwise
|
|
859
|
+
// carried forward. It is safe to read from the text now that every
|
|
860
|
+
// display reads the mark of the one session it describes.
|
|
861
|
+
hookInput && typeof hookInput.prompt === 'string' && /\bultracode\b/i.test(hookInput.prompt)
|
|
862
|
+
? { ultracode: true }
|
|
863
|
+
: {}
|
|
864
|
+
),
|
|
632
865
|
now
|
|
633
866
|
);
|
|
634
867
|
|
|
@@ -674,7 +907,7 @@ async function run(now, hookInput) {
|
|
|
674
907
|
if (!view || !view.binding) {
|
|
675
908
|
// One call, shared with the report. Building the view twice is how the
|
|
676
909
|
// snapshot correction reached the report and never reached the hook.
|
|
677
|
-
const data = await usage.report(now, { sessionId });
|
|
910
|
+
const data = await usage.report(now, { sessionId, budgetMs: SCAN_BUDGET_MS });
|
|
678
911
|
const binding = data.binding;
|
|
679
912
|
view = {
|
|
680
913
|
at: now,
|
|
@@ -691,6 +924,7 @@ async function run(now, hookInput) {
|
|
|
691
924
|
})),
|
|
692
925
|
snapshotAge: usage.formatDuration(data.snapshotAgeMs),
|
|
693
926
|
binding: cacheableBinding(binding),
|
|
927
|
+
effortWarning: data.effortWarning || null,
|
|
694
928
|
};
|
|
695
929
|
writeCache(mergeCache(all, sessionId, view, KEEP_SESSIONS));
|
|
696
930
|
}
|
|
@@ -707,7 +941,33 @@ async function run(now, hookInput) {
|
|
|
707
941
|
binding.headroomMs <= RUNWAY_MENTION_MS;
|
|
708
942
|
// Outside the cache on purpose: the tally moves after every reply.
|
|
709
943
|
const found = tallyContext(tally.readState(), sessionId, now);
|
|
944
|
+
// Learning how the user writes, from the prompt that just arrived. Counters
|
|
945
|
+
// only, no model call, and it never speaks: what it knows is read back by
|
|
946
|
+
// /usage-limits:voice and used when the relay writes as them.
|
|
947
|
+
try {
|
|
948
|
+
if (hookInput && typeof hookInput.prompt === 'string') voice.observe(hookInput.prompt, now);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
// Style is not worth a failed hook.
|
|
951
|
+
}
|
|
952
|
+
// This hook has just paid for a transcript scan, so the corrected figure is
|
|
953
|
+
// in hand. Leave it where the status line can read it: that line redraws far
|
|
954
|
+
// too often to scan for itself, and without this it shows the raw snapshot,
|
|
955
|
+
// which during a heavy session is wrong by tens of points in the flattering
|
|
956
|
+
// direction.
|
|
957
|
+
reading.record(binding, now, usage.isCodex() ? require('./codex.js').homeDir() : null);
|
|
958
|
+
const carry = relayState(now, hookInput, binding, sessionId);
|
|
959
|
+
// An instruction the user typed at /usage-limits:voice set. The learned
|
|
960
|
+
// traits are for writing AS them and stay out of the way; this is them
|
|
961
|
+
// saying how they want to be talked to, so it is said every time.
|
|
962
|
+
let voiceNote = null;
|
|
963
|
+
try {
|
|
964
|
+
voiceNote = voice.read().note;
|
|
965
|
+
} catch (err) {
|
|
966
|
+
voiceNote = null;
|
|
967
|
+
}
|
|
710
968
|
return briefText({
|
|
969
|
+
relay: carry,
|
|
970
|
+
voiceNote,
|
|
711
971
|
lastReply: found.lastReply,
|
|
712
972
|
context: found.context,
|
|
713
973
|
lastSession: found.lastSession,
|
|
@@ -721,8 +981,18 @@ async function run(now, hookInput) {
|
|
|
721
981
|
? usage.formatDuration(now - binding.refusedAt)
|
|
722
982
|
: null,
|
|
723
983
|
binding,
|
|
984
|
+
// Named only while the window is actually tight: at 20 percent nobody
|
|
985
|
+
// needs telling how to free it.
|
|
986
|
+
family:
|
|
987
|
+
binding && binding.family && Number.isFinite(binding.percentUsed) && binding.percentUsed >= HALF_SPENT
|
|
988
|
+
? familyLabel(binding.family)
|
|
989
|
+
: null,
|
|
724
990
|
othersSummary: view.othersSummary,
|
|
725
991
|
turnsLeft: view.turnsLeft,
|
|
992
|
+
effortWarning: view.effortWarning || null,
|
|
993
|
+
// Outside the cache: it is cheap, and it belongs to the other agent's
|
|
994
|
+
// clock rather than this session's.
|
|
995
|
+
codex: codexSummary(now),
|
|
726
996
|
resetsIn:
|
|
727
997
|
binding && !binding.stale && Number.isFinite(binding.resetsAt)
|
|
728
998
|
? usage.formatDuration(binding.resetsAt - now)
|
|
@@ -772,6 +1042,7 @@ module.exports = {
|
|
|
772
1042
|
describeWindow,
|
|
773
1043
|
summariseOthers,
|
|
774
1044
|
briefText,
|
|
1045
|
+
codexSummary,
|
|
775
1046
|
tallyContext,
|
|
776
1047
|
LARGE_CONTEXT_TOKENS,
|
|
777
1048
|
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 };
|