claude-usage-limits 1.8.0 → 1.9.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.8.0",
4
+ "version": "1.9.1",
5
5
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usage-limits",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
5
5
  "author": {
6
6
  "name": "Ridelink",
package/README.md CHANGED
@@ -430,6 +430,44 @@ times one that answers from context. The upper end is the honest one for a long
430
430
  run, since turns get dearer as the context grows.
431
431
 
432
432
 
433
+ ## Which effort and model should this run at
434
+
435
+ ```
436
+ node skills/usage-limits/scripts/usage.js --recommend # against the headroom
437
+ node skills/usage-limits/scripts/usage.js --recommend 15 # against a 15 turn job
438
+ ```
439
+
440
+ ```
441
+ Recommendation for 15 turns
442
+
443
+ Posture tight - a 15 turn job fits, but only just (5-hour window, 12% left, resets in 1h 40m)
444
+ Effort xhigh -> medium; one notch covers the mechanical stretches; keep judgement calls at full effort
445
+ this session: /effort medium (only the user can run it)
446
+ new sessions: node scripts/lowpower.js on --effort medium
447
+ Model keep opus for the judgement; the saving is in where the mechanical bulk runs
448
+ dispatch self-contained mechanical work to a subagent on sonnet at low effort, and keep the judgement here
449
+ ```
450
+
451
+ It weighs the binding window, the measured cost of a turn, and how much of the
452
+ output is actually reasoning, then names a posture - roomy, tight, critical, or
453
+ reset-first - and the exact commands. When there is room it says to keep
454
+ everything as it is, out loud, because turning effort down when the budget is
455
+ not tight buys nothing and costs quality. When reasoning is only a sliver of
456
+ the output it says so too, and leaves effort alone: the reasoning share is the
457
+ ceiling on what lowering effort can save. `--json` returns the decision as an
458
+ object.
459
+
460
+ The three levers it recommends across belong to different hands. The running
461
+ session's effort and model are the user's alone (`/effort`, `/model`, applied
462
+ immediately); new sessions belong to `lowpower.js`, which writes `settings.json`
463
+ for the next launch; and delegated work belongs to the agent itself, which can
464
+ dispatch a subagent on any model at any effort, mid-session, with no one asked.
465
+ No script or hook can change the model or effort of a session already running -
466
+ `settings.json` is read at launch and hook output has no model field - which is
467
+ why the recommendation separates "this session" from "new sessions" instead of
468
+ pretending one command covers both.
469
+
470
+
433
471
  ## Plans
434
472
 
435
473
  It reads which plan you are on and adjusts what it tells you, because the
@@ -545,6 +583,10 @@ overwrite the saved originals.
545
583
  The file change applies to new sessions. For a session already running,
546
584
  `/effort low` does the same thing immediately.
547
585
 
586
+ `--effort max` is refused: `settings.json` does not accept `max`, so saving it
587
+ would store a value the next session silently ignores. `max` lives in
588
+ `/effort` and `CLAUDE_CODE_EFFORT_LEVEL` only.
589
+
548
590
  That covers the setting. The larger saving is behavioural, and the skill file
549
591
  spells it out: batch tool calls, read line ranges instead of whole files, skip
550
592
  subagents when the context already exists, stop retrying a fix that is not
package/bin/cli.js CHANGED
@@ -11,6 +11,7 @@ const HELP = `claude-usage-limits - how much agent usage is left, and whether th
11
11
  claude-usage-limits --json the same numbers, machine readable
12
12
  claude-usage-limits --status one short line, for a status line
13
13
  claude-usage-limits --forecast 15 what a 15 turn job would cost
14
+ claude-usage-limits --recommend [15] which effort and model this budget calls for
14
15
  claude-usage-limits --sessions what recent sessions cost
15
16
  claude-usage-limits --session last one session in full, or give its id
16
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
@@ -272,6 +272,45 @@ to `low` is the largest per-turn saving available without changing model or
272
272
  scope. The file change applies to new sessions; for the session already
273
273
  running, `/effort low` takes effect immediately.
274
274
 
275
+ ### Choosing the level
276
+
277
+ Do not guess which effort or model the budget calls for; the report can say:
278
+
279
+ ```
280
+ node scripts/usage.js --recommend # against the headroom in general
281
+ node scripts/usage.js --recommend 15 # against a 15 turn job
282
+ ```
283
+
284
+ It weighs the binding window, the measured cost of a turn, and how much of
285
+ the output is actually reasoning, then names the posture (roomy, tight,
286
+ critical, or reset-first) and the exact commands. When there is room it says
287
+ to keep everything as it is, out loud, so cheapness never becomes a habit.
288
+ Add `--json` for the decision as fields.
289
+
290
+ The three levers it recommends across belong to different hands, and keeping
291
+ that straight is the whole trick:
292
+
293
+ | Lever | Whose hand | When it acts |
294
+ | --- | --- | --- |
295
+ | `/effort`, `/model` | the user's only | this session, immediately |
296
+ | `lowpower.js on` (writes settings.json) | yours, right now | new sessions, at launch |
297
+ | subagent model and effort | yours, freely | that dispatch, immediately |
298
+
299
+ The running session's own model and effort cannot be changed by any script or
300
+ hook: settings.json is read at launch and hook output has no model field. So
301
+ when the recommendation says `/effort low`, put that in front of the user as
302
+ one short line and keep working; do not wait on it. What can be done without
303
+ asking anyone is the other two rows: write the next session's settings with
304
+ `lowpower.js`, and send self-contained mechanical bulk to a subagent on a
305
+ cheaper model at low effort, which is a change of model that needs nobody's
306
+ permission. The cold start still costs (see `references/tactics.md` on
307
+ subagents), so delegate work that is big and mechanical, not quick questions.
308
+
309
+ One catch to know about: `settings.json` does not accept `max`, so a saved
310
+ effort level tops out at `xhigh`. `max` only survives through `/effort` or the
311
+ `CLAUDE_CODE_EFFORT_LEVEL` environment variable, and `lowpower.js` refuses to
312
+ write it rather than save a value the next session would silently ignore.
313
+
275
314
  Do not take that on trust: the report measures it. Under the model table it
276
315
  says how much of the output was reasoning and what that cost, for example
277
316
 
@@ -394,5 +433,6 @@ a turn, which is the thing it is trying to save.
394
433
  | `scripts/codex.js` | The Codex reader: the meter and the pace out of `~/.codex/sessions`, plus the live `--refresh` call. |
395
434
  | `scripts/install-codex-hook.js` | `status`, `on`, `off`. Installs the Codex-side instruction, which Claude Code does not need. |
396
435
  | `scripts/lowpower.js` | `status`, `on`, `off`. Restores what it replaced. Claude Code only. |
436
+ | `scripts/recommend.js` | The chooser behind `usage.js --recommend`: posture, then the effort and model commands for each lever. Not meant to be called by hand. |
397
437
  | `references/tactics.md` | Every lever that lowers cost, and why it works. |
398
438
  | `references/how-it-works.md` | Where the numbers come from and where they are soft. |
@@ -130,7 +130,17 @@ calculation.
130
130
  **List prices are a proxy.** The rate table is first-party API pricing. How a
131
131
  subscription plan actually meters usage is not published, and the weighting
132
132
  almost certainly is not exactly this. It is close enough for ratios, which is
133
- all it is used for.
133
+ all it is used for. No published or community source shows the meter weighting
134
+ models differently from their dollar prices, so calibrating dollars against
135
+ your own meter remains the best method anyone outside Anthropic has.
136
+
137
+ **The snapshot is slow by design.** The percentages come from Claude Code's
138
+ own cache of the account meter, which refreshes on its own schedule - roughly
139
+ hourly in practice, because the endpoint behind it rate-limits aggressive
140
+ polling. Between refreshes every figure here is the last real reading plus
141
+ arithmetic. That is why an old snapshot is reported as a floor with its age
142
+ attached rather than dressed up as a current percentage, and why `/usage` is
143
+ the one way to force a fresh reading.
134
144
 
135
145
  **A reset time can be in the past.** The cache refreshes when Claude Code
136
146
  talks to the API, so an idle spell leaves it behind. A window whose `resets_at`
@@ -168,6 +178,13 @@ immediately. Re-run the report if the shape of the work changes.
168
178
  The rate table in `scripts/usage.js` is a plain object at the top of the file.
169
179
  When new models ship, add a row.
170
180
 
181
+ A bracketed suffix on a model id (`claude-sonnet-5[1m]`) is stripped before
182
+ the lookup: it marks a context-window variant of the same model, not a new
183
+ one. Cache reads price at a tenth of the input rate unless a row carries a
184
+ `cacheRead` figure of its own - Fable and Mythos 5.1 price reads outright at
185
+ $0.25 per million, far under the tenth rule, and reads are the dominant input
186
+ in exactly the long sessions where the difference matters.
187
+
171
188
  Until someone does, a model this table has not seen is priced at the average of
172
189
  the family its name contains: an unreleased `claude-opus-5-2` is charged at the
173
190
  mean of every Opus rate on record. Averaging assumes nothing about which
@@ -56,6 +56,13 @@ Switch at a task boundary rather than mid-task, because a model switch
56
56
  invalidates the prompt cache and the rebuild can cost more than the saving on
57
57
  a short remaining task.
58
58
 
59
+ The switch needs no boundary at all when the work is delegated: a subagent
60
+ can be dispatched on a cheaper model at low effort mid-session, and that is
61
+ the one model change the agent can make without anyone's hand on `/model`.
62
+ It pays the cold start from lever 7, so it is for self-contained bulky work,
63
+ not quick questions. `node scripts/usage.js --recommend` prices whether the
64
+ budget calls for it.
65
+
59
66
  ### 3. Read less, and read it once
60
67
 
61
68
  - Line ranges instead of whole files. Grep with a result limit instead of
@@ -502,7 +502,9 @@ function briefText(parts) {
502
502
  'save at clean boundaries as you go, and keep a short running note of ' +
503
503
  'what is done, what is next, and which files are mid-change, so that ' +
504
504
  'stopping at any moment loses nothing. Say in one line what may not ' +
505
- 'land before the reset, then keep working.'
505
+ 'land before the reset, then keep working. If part of what remains is ' +
506
+ 'mechanical, node scripts/usage.js --recommend (from the skill directory) ' +
507
+ 'names the effort and model it should run at.'
506
508
  : 'Open your reply with one short line stating this and confirming the ' +
507
509
  'request fits, then get on with the work. Keep it to a single line. ' +
508
510
  'There is room, so use it: work at full quality, take on the whole ' +
@@ -81,6 +81,15 @@ function planApply(settings, options, existingState) {
81
81
  if (EFFORT_LEVELS.indexOf(effort) === -1) {
82
82
  throw new Error('unknown effort "' + effort + '", expected one of ' + EFFORT_LEVELS.join(', '));
83
83
  }
84
+ // Claude Code refuses 'max' in settings.json; it only survives through
85
+ // /effort or CLAUDE_CODE_EFFORT_LEVEL. Writing it here would save a value
86
+ // the next session silently ignores, which is worse than an error.
87
+ if (effort === 'max') {
88
+ throw new Error(
89
+ "settings.json does not accept 'max'. Use --effort xhigh here, and /effort max " +
90
+ 'or CLAUDE_CODE_EFFORT_LEVEL=max for the sessions that need it.'
91
+ );
92
+ }
84
93
  wanted.effortLevel = effort;
85
94
  if (options.model) wanted.model = options.model;
86
95
 
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ // Turns the budget figures into a choice of effort and model, and says where
4
+ // each half of that choice can actually be applied. There are three levers and
5
+ // they belong to different hands:
6
+ //
7
+ // - The running session's effort and model are the user's: only /effort and
8
+ // /model change them, and they change them immediately.
9
+ // - New sessions are the script's: lowpower.js writes effortLevel and model
10
+ // into settings.json, which Claude Code reads at launch.
11
+ // - Delegated work is Claude's alone: a subagent can be dispatched on any
12
+ // model at any effort, mid-session, with no one asked.
13
+ //
14
+ // Nothing here touches disk. decide() is a pure function over the same report
15
+ // data usage.js already gathers, so the reasoning can be tested without a
16
+ // transcript in sight.
17
+
18
+ // One notch down, not a cliff. Dropping xhigh to low on work that still has
19
+ // judgement in it costs more in rework than it saves; the ladder loses height
20
+ // a step at a time and 'critical' is the only posture that goes straight to
21
+ // the floor.
22
+ const NEXT_LOWER = { max: 'high', xhigh: 'medium', high: 'medium', medium: 'low', low: 'low' };
23
+
24
+ // Below this share of output, reasoning is not where the money is going, and
25
+ // turning effort down would trade quality for a saving that is not there.
26
+ const REASONING_FLOOR = 0.1;
27
+
28
+ // Turns-left walls used when no job size is given. Ten turns is barely a
29
+ // feature; twenty-five is room for one, carefully.
30
+ const CRITICAL_TURNS = 10;
31
+ const TIGHT_TURNS = 25;
32
+
33
+ const HOUR = 60 * 60 * 1000;
34
+
35
+ // Where the mechanical bulk should go when it is delegated. One tier down
36
+ // from whatever is doing the judgement; haiku is already the floor.
37
+ function delegateModel(model) {
38
+ const name = String(model || '').toLowerCase();
39
+ if (name.indexOf('haiku') !== -1) return 'haiku';
40
+ if (name.indexOf('sonnet') !== -1) return 'haiku';
41
+ return 'sonnet';
42
+ }
43
+
44
+ // The effort actually in force. settings.json says 'default' when nothing is
45
+ // set, and the measured dominant effort of recent turns is better evidence
46
+ // than a guess; xhigh is what Claude Code defaults to when neither knows.
47
+ function currentEffort(settings, recentEffort) {
48
+ const set = settings && settings.effortLevel;
49
+ if (set && set !== 'default') return set;
50
+ if (recentEffort) return recentEffort;
51
+ return 'xhigh';
52
+ }
53
+
54
+ function decide(inputs) {
55
+ const binding = inputs.binding;
56
+ const rates = inputs.rates;
57
+ const settings = inputs.settings || {};
58
+ const effortNow = currentEffort(settings, inputs.recentEffort);
59
+ const modelNow = (settings.model && settings.model !== 'default' && settings.model) || 'default';
60
+
61
+ const base = {
62
+ posture: 'unknown',
63
+ reason: null,
64
+ turnsLeft: null,
65
+ effort: { current: effortNow, target: effortNow, changes: false, why: null },
66
+ model: { current: modelNow, delegate: null, nextSession: null, why: null },
67
+ apply: { now: null, next: null, delegate: null },
68
+ notes: [],
69
+ };
70
+
71
+ if (!binding || binding.stale) {
72
+ base.reason = 'no fresh reading of the binding window';
73
+ return base;
74
+ }
75
+ if (!rates || !Number.isFinite(rates.median) || rates.median <= 0) {
76
+ base.reason = 'no measured turn cost to price the budget in turns';
77
+ return base;
78
+ }
79
+ if (!Number.isFinite(binding.usdPerPercent) || binding.usdPerPercent <= 0) {
80
+ base.reason = 'the binding window has no calibrated price per point yet';
81
+ return base;
82
+ }
83
+
84
+ const percentLeft = Number.isFinite(binding.percentLeft) ? binding.percentLeft : 0;
85
+ const turnsLeft = Math.floor((percentLeft * binding.usdPerPercent) / rates.median);
86
+ base.turnsLeft = turnsLeft;
87
+
88
+ // When the clock wins the race, the limit is not the constraint and there
89
+ // is nothing to buy by economising: whatever is left at the reset is lost.
90
+ const pace = inputs.recentTurnsPerHour;
91
+ if (
92
+ Number.isFinite(binding.msToReset) &&
93
+ binding.msToReset > 0 &&
94
+ Number.isFinite(pace) &&
95
+ pace > 0 &&
96
+ (binding.msToReset / HOUR) * pace < turnsLeft * 0.8
97
+ ) {
98
+ base.posture = 'reset-first';
99
+ base.reason = 'the window resets before this pace can spend it';
100
+ base.effort.why = 'the budget is not the constraint';
101
+ base.model.why = 'the budget is not the constraint';
102
+ return base;
103
+ }
104
+
105
+ // With a job size, the forecast arithmetic decides. Without one, the raw
106
+ // turns of headroom do. Both use the expensive end of the measured spread,
107
+ // because that is the honest number for a long run.
108
+ if (Number.isFinite(inputs.turns) && inputs.turns > 0) {
109
+ const percentHigh = (inputs.turns * rates.high) / binding.usdPerPercent;
110
+ if (percentHigh > percentLeft) {
111
+ base.posture = 'critical';
112
+ base.reason = 'a ' + inputs.turns + ' turn job does not fit in what is left';
113
+ } else if (percentHigh > percentLeft * 0.75) {
114
+ base.posture = 'tight';
115
+ base.reason = 'a ' + inputs.turns + ' turn job fits, but only just';
116
+ } else {
117
+ base.posture = 'roomy';
118
+ base.reason = 'a ' + inputs.turns + ' turn job fits with room to spare';
119
+ }
120
+ } else if (percentLeft <= 0 || turnsLeft <= 0) {
121
+ base.posture = 'critical';
122
+ base.reason = 'the binding window is spent';
123
+ } else if (turnsLeft <= CRITICAL_TURNS) {
124
+ base.posture = 'critical';
125
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
126
+ } else if (turnsLeft <= TIGHT_TURNS) {
127
+ base.posture = 'tight';
128
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
129
+ } else {
130
+ base.posture = 'roomy';
131
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
132
+ }
133
+
134
+ if (base.posture === 'roomy') {
135
+ base.effort.why = 'cheapness is not a virtue when the budget is not tight';
136
+ base.model.why = 'cheapness is not a virtue when the budget is not tight';
137
+ return base;
138
+ }
139
+
140
+ // Effort is the biggest per-turn lever, but only when reasoning is actually
141
+ // where the money goes. The reasoning share is the ceiling on the saving,
142
+ // so a small share means the honest advice is to leave effort alone.
143
+ const share = inputs.reasoningShare;
144
+ if (Number.isFinite(share) && share < REASONING_FLOOR) {
145
+ base.effort.why =
146
+ 'reasoning is only ' + Math.round(share * 100) +
147
+ '% of output, so effort is not where the money is going';
148
+ } else {
149
+ const target = base.posture === 'critical' ? 'low' : NEXT_LOWER[effortNow] || 'medium';
150
+ if (target !== effortNow) {
151
+ base.effort.target = target;
152
+ base.effort.changes = true;
153
+ base.effort.why =
154
+ base.posture === 'critical'
155
+ ? 'reasoning is billed as output, and low is the largest saving that changes nothing else'
156
+ : 'one notch covers the mechanical stretches; keep judgement calls at full effort';
157
+ } else {
158
+ base.effort.why = 'already at the floor for this posture';
159
+ }
160
+ }
161
+
162
+ // The main model is only worth flipping when things are critical, and even
163
+ // then it lands in settings.json for the next session: switching the running
164
+ // session's model mid-task invalidates the prompt cache, so the change
165
+ // belongs at a session boundary.
166
+ base.model.delegate = delegateModel(modelNow);
167
+ base.model.why =
168
+ 'keep ' + (modelNow === 'default' ? 'the current model' : modelNow) +
169
+ ' for the judgement; the saving is in where the mechanical bulk runs';
170
+ if (base.posture === 'critical' && base.model.delegate !== 'haiku') {
171
+ base.model.nextSession = 'sonnet';
172
+ }
173
+
174
+ // The commands, spelled out, because the point of a recommendation is that
175
+ // it can be acted on without working anything out.
176
+ if (base.effort.changes) {
177
+ base.apply.now = '/effort ' + base.effort.target;
178
+ }
179
+ if (inputs.codex) {
180
+ base.notes.push(
181
+ 'Under Codex, settings.json is not in play: change model or effort through ' +
182
+ "Codex's own controls."
183
+ );
184
+ } else if (base.effort.changes || base.model.nextSession) {
185
+ const settingsEffort = base.effort.changes ? base.effort.target : effortNow;
186
+ base.apply.next =
187
+ 'node scripts/lowpower.js on --effort ' +
188
+ (settingsEffort === 'max' ? 'xhigh' : settingsEffort) +
189
+ (base.model.nextSession ? ' --model ' + base.model.nextSession : '');
190
+ }
191
+ base.apply.delegate =
192
+ 'dispatch self-contained mechanical work to a subagent on ' +
193
+ base.model.delegate +
194
+ ' at low effort, and keep the judgement here';
195
+
196
+ if (Number.isFinite(inputs.sessions) && inputs.sessions > 1) {
197
+ base.notes.push(
198
+ inputs.sessions + ' sessions are spending this budget at once, so the headroom ' +
199
+ 'drains faster than these figures alone suggest.'
200
+ );
201
+ }
202
+ if (effortNow === 'max') {
203
+ base.notes.push(
204
+ "settings.json does not accept 'max', so a saved level can only go up to " +
205
+ 'xhigh; max survives only through /effort or CLAUDE_CODE_EFFORT_LEVEL.'
206
+ );
207
+ }
208
+
209
+ return base;
210
+ }
211
+
212
+ // The report data usage.js gathers, reduced to what decide() reads.
213
+ function fromReport(data, turns) {
214
+ return {
215
+ binding: (data && data.binding) || null,
216
+ rates: (data && data.rates) || null,
217
+ settings: (data && data.settings) || {},
218
+ recentEffort: data && data.recent ? data.recent.effort : null,
219
+ recentTurnsPerHour: data && data.recent ? data.recent.turns : null,
220
+ reasoningShare: data && data.reasoning ? data.reasoning.shareOfOutput : null,
221
+ sessions: data && data.sessions ? data.sessions.length : 1,
222
+ codex: Boolean(data && data.money === false),
223
+ turns: Number.isFinite(turns) ? turns : null,
224
+ };
225
+ }
226
+
227
+ // Local and small on purpose: requiring usage.js back for its formatters
228
+ // would make the two modules a cycle.
229
+ function fmtDuration(ms) {
230
+ if (!Number.isFinite(ms) || ms <= 0) return 'now';
231
+ const minutes = Math.round(ms / 60000);
232
+ if (minutes < 60) return minutes + 'm';
233
+ const hours = Math.floor(minutes / 60);
234
+ if (hours < 48) return hours + 'h ' + (minutes % 60) + 'm';
235
+ return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h';
236
+ }
237
+
238
+ function renderRecommend(data, turns) {
239
+ const decision = decide(fromReport(data, turns));
240
+ const lines = [];
241
+ lines.push('Recommendation' + (Number.isFinite(turns) && turns > 0 ? ' for ' + turns + ' turns' : ''));
242
+ lines.push('');
243
+
244
+ if (decision.posture === 'unknown') {
245
+ lines.push(' Nothing to recommend yet: ' + decision.reason + '.');
246
+ lines.push(' Run /usage once, do a little work, then ask again.');
247
+ return lines.join('\n');
248
+ }
249
+
250
+ const binding = data.binding;
251
+ const where =
252
+ binding.label +
253
+ ' window, ' +
254
+ (Number.isFinite(binding.percentLeft) ? Math.max(0, Math.round(binding.percentLeft)) : '?') +
255
+ '% left' +
256
+ (Number.isFinite(binding.msToReset) ? ', resets in ' + fmtDuration(binding.msToReset) : '');
257
+ lines.push(' Posture ' + decision.posture + ' - ' + decision.reason + ' (' + where + ')');
258
+
259
+ if (decision.posture === 'roomy' || decision.posture === 'reset-first') {
260
+ lines.push(' Effort keep ' + decision.effort.current + '; ' + decision.effort.why);
261
+ lines.push(' Model keep ' + decision.model.current + '; do not economise');
262
+ return lines.join('\n');
263
+ }
264
+
265
+ if (decision.effort.changes) {
266
+ lines.push(' Effort ' + decision.effort.current + ' -> ' + decision.effort.target + '; ' + decision.effort.why);
267
+ lines.push(' this session: ' + decision.apply.now + ' (only the user can run it)');
268
+ } else {
269
+ lines.push(' Effort keep ' + decision.effort.current + '; ' + decision.effort.why);
270
+ }
271
+ if (decision.apply.next) {
272
+ lines.push(' new sessions: ' + decision.apply.next);
273
+ }
274
+ lines.push(' Model ' + decision.model.why);
275
+ lines.push(' ' + decision.apply.delegate);
276
+ if (decision.model.nextSession) {
277
+ lines.push(' new sessions: main model to ' + decision.model.nextSession + ' until the window resets');
278
+ }
279
+ for (const note of decision.notes) {
280
+ lines.push(' Note ' + note);
281
+ }
282
+ return lines.join('\n');
283
+ }
284
+
285
+ module.exports = {
286
+ decide,
287
+ fromReport,
288
+ renderRecommend,
289
+ delegateModel,
290
+ currentEffort,
291
+ NEXT_LOWER,
292
+ REASONING_FLOOR,
293
+ CRITICAL_TURNS,
294
+ TIGHT_TURNS,
295
+ };
@@ -47,8 +47,12 @@ const MINUTE = 60 * 1000;
47
47
  const HOUR = 60 * MINUTE;
48
48
  const DAY = 24 * HOUR;
49
49
 
50
- // USD per million tokens, first-party API rates.
50
+ // USD per million tokens, first-party API rates. `cacheRead` is an absolute
51
+ // $/MTok override for the few models that price reads outright instead of at
52
+ // a tenth of input; everything else uses the CACHE_READ multiplier below.
51
53
  const RATES = {
54
+ 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25 },
55
+ 'claude-mythos-5-1': { input: 10, output: 50, cacheRead: 0.25 },
52
56
  'claude-fable-5': { input: 10, output: 50 },
53
57
  'claude-mythos-5': { input: 10, output: 50 },
54
58
  'claude-opus-5': { input: 5, output: 25 },
@@ -91,9 +95,21 @@ function familyAverage(family, table) {
91
95
  return { input: input / members.length, output: output / members.length };
92
96
  }
93
97
 
98
+ // Claude Code aliases and some transcript records carry a bracketed variant
99
+ // suffix - "fable[1m]" is the 1M-context toggle on the same model, not a
100
+ // different one. Left in place it misses the exact rate lookup and lands on
101
+ // the family average, which is wrong whenever a family's members price
102
+ // differently (sonnet 5 at $2 against sonnet 4.6 at $3).
103
+ function normalizeModel(model) {
104
+ return String(model || '')
105
+ .toLowerCase()
106
+ .replace(/\[[^\]]*\]\s*$/, '')
107
+ .trim();
108
+ }
109
+
94
110
  // Whether the price came from the table or from an assumption.
95
111
  function isKnownModel(model) {
96
- return Object.prototype.hasOwnProperty.call(RATES, String(model || '').toLowerCase());
112
+ return Object.prototype.hasOwnProperty.call(RATES, normalizeModel(model));
97
113
  }
98
114
 
99
115
  // Cache traffic is priced as a multiple of the input rate.
@@ -184,7 +200,7 @@ function readJson(file) {
184
200
  }
185
201
 
186
202
  function rateFor(model) {
187
- const id = String(model || '').toLowerCase();
203
+ const id = normalizeModel(model);
188
204
  if (RATES[id]) return RATES[id];
189
205
  return familyAverage(familyOf(id)) || FALLBACK_RATE;
190
206
  }
@@ -199,16 +215,26 @@ function costOf(usage, model) {
199
215
 
200
216
  let writeUnits = write5m * CACHE_WRITE_5M + write1h * CACHE_WRITE_1H;
201
217
  if (writeUnits === 0) {
202
- // Older records only carry the undifferentiated total.
218
+ // Older records only carry the undifferentiated total. Five minutes is
219
+ // the default TTL, so that is the assumption; an old-format one-hour
220
+ // session is under-priced by it, but assuming 2x would overcharge the
221
+ // common case to be right about the rare one.
203
222
  writeUnits = (usage.cache_creation_input_tokens || 0) * CACHE_WRITE_5M;
204
223
  }
205
224
 
206
- const inputUnits =
207
- (usage.input_tokens || 0) +
208
- (usage.cache_read_input_tokens || 0) * CACHE_READ +
209
- writeUnits;
225
+ // Reads price at a tenth of the input rate unless the model prices them
226
+ // outright. The distinction matters most exactly where reads dominate: a
227
+ // long session re-reads its whole context every turn, and pricing Fable
228
+ // 5.1's $0.25 reads by the tenth rule would overstate that spend fourfold.
229
+ const readTokens = usage.cache_read_input_tokens || 0;
230
+ const readCost = Number.isFinite(rate.cacheRead)
231
+ ? readTokens * rate.cacheRead
232
+ : readTokens * CACHE_READ * rate.input;
210
233
 
211
- return (inputUnits * rate.input + (usage.output_tokens || 0) * rate.output) / 1e6;
234
+ const inputUnits = (usage.input_tokens || 0) + writeUnits;
235
+ return (
236
+ (inputUnits * rate.input + readCost + (usage.output_tokens || 0) * rate.output) / 1e6
237
+ );
212
238
  }
213
239
 
214
240
  function tokensOf(usage) {
@@ -812,13 +838,24 @@ function stampPlan(all, planId) {
812
838
  return stamped;
813
839
  }
814
840
 
815
- // A sample is better when it rests on more turns. Percentages read in whole
816
- // numbers, so a bigger percentage also divides more precisely.
841
+ // A sample is better when it rests on more of the meter. Percentages read in
842
+ // whole numbers, so a reading at 1% prices a point against a bracket that is
843
+ // mostly rounding, while one at 60% divides by a number that means something.
844
+ // Turn count only breaks the tie: it says how much local spend sat behind the
845
+ // reading, not how precise the denominator was, and preferring it outright is
846
+ // how a 44-turn baseline read at 1% once beat every honest sample after it.
817
847
  function betterCalibration(current, candidate) {
818
848
  if (!candidate || !Number.isFinite(candidate.usdPerPercent) || candidate.usdPerPercent <= 0) {
819
849
  return current || null;
820
850
  }
821
851
  if (!current || !Number.isFinite(current.turns)) return candidate;
852
+ if (
853
+ Number.isFinite(candidate.percent) &&
854
+ Number.isFinite(current.percent) &&
855
+ candidate.percent !== current.percent
856
+ ) {
857
+ return candidate.percent > current.percent ? candidate : current;
858
+ }
822
859
  return candidate.turns > current.turns ? candidate : current;
823
860
  }
824
861
 
@@ -933,16 +970,23 @@ function buildWindow(spec, snapshot, events, now, options) {
933
970
  // holding. The learned price covers it: what a point costs is a property
934
971
  // of the plan, not of this reading.
935
972
  const selfPriced =
936
- rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
973
+ rawPercent >= MIN_BASELINE_PERCENT && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
937
974
  ? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
938
975
  : null;
939
976
  // A metered window has nothing to learn: its price per point is stated.
940
977
  if (selfPriced && !extra.metered) window.calibration = selfPriced;
941
978
 
942
979
  const known = extra.knownCalibration;
980
+ // A remembered price read off a near-empty meter is the same rounding
981
+ // bracket in disguise, so it is no more usable than measuring one now.
943
982
  const usable =
944
- known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
945
- // Trust the better-sampled of the two, whichever that is; a stated price
983
+ known &&
984
+ Number.isFinite(known.usdPerPercent) &&
985
+ known.usdPerPercent > 0 &&
986
+ !(Number.isFinite(known.percent) && known.percent < MIN_BASELINE_PERCENT)
987
+ ? known
988
+ : null;
989
+ // Trust the better-measured of the two, whichever that is; a stated price
946
990
  // beats both.
947
991
  const stated =
948
992
  extra.metered && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
@@ -950,9 +994,7 @@ function buildWindow(spec, snapshot, events, now, options) {
950
994
  : null;
951
995
  const chosen =
952
996
  stated ||
953
- (selfPriced && usable
954
- ? (usable.turns > selfPriced.turns ? usable : selfPriced)
955
- : selfPriced || usable);
997
+ (selfPriced && usable ? betterCalibration(usable, selfPriced) : selfPriced || usable);
956
998
 
957
999
  if (after.cost > 0 && chosen) {
958
1000
  const pricePerPoint = chosen.usdPerPercent;
@@ -988,7 +1030,12 @@ function buildWindow(spec, snapshot, events, now, options) {
988
1030
  // so when the two disagree that badly it is the local history that is
989
1031
  // incomplete, not the meter.
990
1032
  const thin = spent.turns < MIN_BASELINE_TURNS && percent >= UNEXPLAINED_PERCENT;
991
- const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
1033
+ // The same rounding bracket again, in the optimistic direction this time: a
1034
+ // window reading 1% divided a full hour of spend by one and priced the
1035
+ // remaining 99 points at thousands of dollars. Below the floor the learned
1036
+ // price takes over through the fallback chain.
1037
+ const measured =
1038
+ percent !== null && percent >= MIN_BASELINE_PERCENT && spent.cost > 0 && !thin;
992
1039
  const derived = measured ? spent.cost / percent : null;
993
1040
  const known = extra.knownCalibration;
994
1041
  const metered =
@@ -1004,11 +1051,14 @@ function buildWindow(spec, snapshot, events, now, options) {
1004
1051
  : null;
1005
1052
  window.metered = metered;
1006
1053
 
1054
+ // The API reports whole numbers, so a low reading is a wide bracket. A fact
1055
+ // about the reading, not the pricing, so it is set whether or not a price
1056
+ // per point could be found.
1057
+ window.coarse = percent !== null && percent < MIN_BASELINE_PERCENT;
1058
+
1007
1059
  if (percent !== null && priced !== null) {
1008
1060
  window.usdPerPercent = priced;
1009
1061
  window.remainingUSD = window.usdPerPercent * window.percentLeft;
1010
- // The API reports whole numbers, so a low reading is a wide bracket.
1011
- window.coarse = percent < 5;
1012
1062
 
1013
1063
  const perTurn = typicalTurnCost(recentEvents, inWindow, events, MIN_PACE_SAMPLE);
1014
1064
  window.percentPerTurn = perTurn === null ? null : perTurn / window.usdPerPercent;
@@ -1313,6 +1363,15 @@ const SATURATION_LIMIT = 105;
1313
1363
  // Fewer turns than this before the snapshot and a point cannot be priced.
1314
1364
  const MIN_BASELINE_TURNS = 5;
1315
1365
 
1366
+ // A reading below this cannot price a point either. The API reports whole
1367
+ // numbers, so at 1% the denominator is mostly rounding: the true figure is
1368
+ // anywhere in a bracket as wide as the reading itself, and a point priced
1369
+ // against it converts later spend into several times the points it really
1370
+ // moved. A snapshot taken just after a reset is the common case - one sat at
1371
+ // 1% while the local spend divided by it asserted 97% of a window that was
1372
+ // truly at 35.
1373
+ const MIN_BASELINE_PERCENT = 5;
1374
+
1316
1375
  // Past this much of a window, a handful of local turns is not what spent it,
1317
1376
  // so their total is not a fair price for a point.
1318
1377
  const UNEXPLAINED_PERCENT = 20;
@@ -1328,6 +1387,10 @@ function reconstructWindow(spec, snapshot, events, now) {
1328
1387
  const past = totals(events.filter((e) => e.at >= pastStart && e.at <= resetsAt));
1329
1388
  if (past.cost <= 0) return null;
1330
1389
 
1390
+ // The same rounding bracket that poisons the live correction poisons a
1391
+ // rebuild: a closed window that read 1% prices a point off almost nothing.
1392
+ if (snapshot.utilization < MIN_BASELINE_PERCENT) return null;
1393
+
1331
1394
  const usdPerPercent = past.cost / snapshot.utilization;
1332
1395
 
1333
1396
  // The window running now began when the old one reset, not five hours ago.
@@ -2261,6 +2324,23 @@ async function main(argv) {
2261
2324
  }
2262
2325
  return 0;
2263
2326
  }
2327
+ const recommendAt = argv.indexOf('--recommend');
2328
+ if (recommendAt !== -1) {
2329
+ // The turn count is optional: with one the verdict is about that job,
2330
+ // without one it is about the headroom in general.
2331
+ const next = argv[recommendAt + 1];
2332
+ const turns = next && next.indexOf('--') !== 0 ? Number(next) : null;
2333
+ const recommend = require('./recommend.js');
2334
+ if (wantsJson) {
2335
+ process.stdout.write(
2336
+ JSON.stringify(recommend.decide(recommend.fromReport(data, turns)), null, 2) + '\n'
2337
+ );
2338
+ } else {
2339
+ process.stdout.write(recommend.renderRecommend(data, turns) + '\n');
2340
+ }
2341
+ return 0;
2342
+ }
2343
+
2264
2344
  if (wantsJson) {
2265
2345
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
2266
2346
  } else {
@@ -2295,6 +2375,7 @@ module.exports = {
2295
2375
  reconstructWindow,
2296
2376
  SATURATION_LIMIT,
2297
2377
  MIN_BASELINE_TURNS,
2378
+ MIN_BASELINE_PERCENT,
2298
2379
  buildWindows,
2299
2380
  limitWindows,
2300
2381
  lastRejections,