claude-usage-limits 1.1.0 → 1.1.2

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.1.0",
4
+ "version": "1.1.2",
5
5
  "description": "Reads how much of your Claude Code usage limit is left, converts it into turns of headroom, and plans the work to fit inside it. Includes a low power switch that keeps spending down even at high effort.",
6
6
  "author": {
7
7
  "name": "Ridelink",
package/README.md CHANGED
@@ -100,6 +100,29 @@ git clone https://github.com/ridelink0/claude-code-usage-limits
100
100
  Copy-Item -Recurse claude-code-usage-limits\skills\usage-limits "$env:USERPROFILE\.claude\skills\usage-limits"
101
101
  ```
102
102
 
103
+ One difference between the two: the hook that puts the budget line in front of
104
+ every prompt is declared in the plugin manifest, so it only runs on a plugin
105
+ install. If you took the plain skill and want that behaviour, add it yourself
106
+ in `~/.claude/settings.json`, pointing at wherever you put the skill:
107
+
108
+ ```json
109
+ {
110
+ "hooks": {
111
+ "UserPromptSubmit": [
112
+ {
113
+ "hooks": [
114
+ {
115
+ "type": "command",
116
+ "command": "node \"$HOME/.claude/skills/usage-limits/scripts/brief.js\"",
117
+ "timeout": 10
118
+ }
119
+ ]
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ ```
125
+
103
126
  Either way, ask something like "how much usage do I have left" or "can we
104
127
  finish this before the limit hits" and Claude will load it. Installed as a
105
128
  plugin it also gives you `/usage-limits:check`, which prints the report and
@@ -326,6 +349,9 @@ Good enough to plan with, not a bill. The honest caveats:
326
349
  a percentage of the limit. On a subscription plan you are not billed them.
327
350
  - Turns left assumes the next turns look like the last hour's. A debugging
328
351
  spiral breaks that assumption immediately.
352
+ - A model released after this table was written is priced at its family's
353
+ average rate, and the report marks those rows with an asterisk rather than
354
+ passing the guess off as a published price.
329
355
  - The cache only refreshes when Claude Code talks to the API, so after an idle
330
356
  spell a window can sit past its own reset time. When that happens the report
331
357
  says `stale` and the status line says `rolling` rather than reporting a
@@ -339,9 +365,13 @@ names, the formulas, and the rest of it.
339
365
  ```
340
366
  .claude-plugin/plugin.json plugin manifest
341
367
  .claude-plugin/marketplace.json lets the repo serve itself
342
- skills/usage-limits/SKILL.md what Claude reads
343
- skills/usage-limits/scripts/ the two scripts
344
- skills/usage-limits/references/ the longer notes
368
+ skills/usage-limits/SKILL.md what Claude reads
369
+ skills/usage-limits/scripts/ usage.js, lowpower.js, brief.js
370
+ skills/usage-limits/references/ the longer notes
371
+ hooks/hooks.json runs brief.js before each prompt
372
+ commands/check.md the /usage-limits:check command
373
+ bin/cli.js the npx entry point
374
+ tools/sync-version.js keeps the manifest version in step
345
375
  test/ node --test, no dependencies
346
376
  ```
347
377
 
@@ -351,7 +381,7 @@ test/ node --test, no dependencies
351
381
  node --test
352
382
  ```
353
383
 
354
- 105 tests over the pricing, the window arithmetic, plan and credit detection,
384
+ 119 tests over the pricing, the window arithmetic, plan and credit detection,
355
385
  the status line, the before-prompt line, job forecasting, per-project
356
386
  attribution, the CLI, packaging, and the settings save/restore.
357
387
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "See how much of your Claude Code usage limit is left as turns of work rather than a percentage, and plan the job to fit inside it.",
5
5
  "keywords": [
6
6
  "claude",
@@ -198,7 +198,8 @@ a turn, which is the thing it is trying to save.
198
198
 
199
199
  | Path | What it is |
200
200
  | --- | --- |
201
- | `scripts/usage.js` | The report. `--json` for raw fields, `--status` for a one-line status line readout that skips the transcript scan. |
201
+ | `scripts/usage.js` | The report. `--json` for raw fields, `--status` for a one-line readout that skips the transcript scan, `--forecast N` for what an N turn job would cost. |
202
+ | `scripts/brief.js` | What the hook runs before each prompt. Not meant to be called by hand. |
202
203
  | `scripts/lowpower.js` | `status`, `on`, `off`. Restores what it replaced. |
203
204
  | `references/tactics.md` | Every lever that lowers cost, and why it works. |
204
205
  | `references/how-it-works.md` | Where the numbers come from and where they are soft. |
@@ -131,6 +131,18 @@ immediately. Re-run the report if the shape of the work changes.
131
131
  ## Keeping it accurate
132
132
 
133
133
  The rate table in `scripts/usage.js` is a plain object at the top of the file.
134
- When new models ship, add a row. An unknown id falls back to the family it
135
- names (`opus`, `sonnet`, `haiku`, `fable`) and then to Opus rates, so a missing
136
- row degrades to an estimate rather than a crash.
134
+ When new models ship, add a row.
135
+
136
+ Until someone does, a model this table has not seen is priced at the average of
137
+ the family its name contains: an unreleased `claude-opus-5-2` is charged at the
138
+ mean of every Opus rate on record. Averaging assumes nothing about which
139
+ direction prices moved, which is why it beats pinning to whichever release
140
+ happened to be newest when the table was written.
141
+
142
+ A name with no recognisable family falls back to Opus rates. That is a
143
+ deliberate choice rather than a neutral one: over-estimating cost understates
144
+ your headroom, and being told you have less room than you do is the safe way to
145
+ be wrong about a budget.
146
+
147
+ Rows priced this way are marked with an asterisk in the report, so an assumed
148
+ rate never quietly passes for a published one.
@@ -38,18 +38,41 @@ function cacheFile() {
38
38
  return path.join(configDir(), 'usage-limits-brief.json');
39
39
  }
40
40
 
41
+ // One slot per session. A single shared slot meant that alternating between
42
+ // two Claude Code windows invalidated the cache on every prompt, so neither
43
+ // ever got a hit and both paid for a full scan each time.
44
+ const KEEP_SESSIONS = 5;
45
+
41
46
  function readCache() {
42
47
  try {
43
- return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
48
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
49
+ return parsed && typeof parsed === 'object' ? parsed : {};
44
50
  } catch (err) {
45
- return null;
51
+ return {};
46
52
  }
47
53
  }
48
54
 
49
- function writeCache(value) {
55
+ function pickCached(all, sessionId, now, ttlMs) {
56
+ const entry = all ? all[sessionId || '_'] : null;
57
+ if (!entry || !Number.isFinite(entry.at)) return null;
58
+ return now - entry.at < ttlMs ? entry : null;
59
+ }
60
+
61
+ // Keep the newest few so a machine with many sessions does not grow the file
62
+ // without bound.
63
+ function mergeCache(all, sessionId, entry, keep) {
64
+ const next = Object.assign({}, all || {});
65
+ next[sessionId || '_'] = entry;
66
+ const ordered = Object.keys(next).sort((a, b) => (next[b].at || 0) - (next[a].at || 0));
67
+ const trimmed = {};
68
+ for (const key of ordered.slice(0, keep || KEEP_SESSIONS)) trimmed[key] = next[key];
69
+ return trimmed;
70
+ }
71
+
72
+ function writeCache(all) {
50
73
  try {
51
74
  fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
52
- fs.writeFileSync(cacheFile(), JSON.stringify(value), 'utf8');
75
+ fs.writeFileSync(cacheFile(), JSON.stringify(all), 'utf8');
53
76
  } catch (err) {
54
77
  // A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
55
78
  }
@@ -186,24 +209,20 @@ async function run(now, hookInput) {
186
209
  const cheap = usage.buildWindows(base.utilization, [], now);
187
210
  if (!cheap.length) return '';
188
211
 
189
- const cached = readCache();
190
- const fresh =
191
- cached &&
192
- Number.isFinite(cached.at) &&
193
- now - cached.at < config.cacheSeconds * SECOND &&
194
- cached.sessionId === sessionId;
212
+ const all = readCache();
213
+ const cached = pickCached(all, sessionId, now, config.cacheSeconds * SECOND);
195
214
 
196
- let turnsLeft = fresh ? cached.turnsLeft : null;
197
- let session = fresh ? cached.session : null;
215
+ let turnsLeft = cached ? cached.turnsLeft : null;
216
+ let session = cached ? cached.session : null;
198
217
  let windows = cheap;
199
218
 
200
- if (!fresh) {
219
+ if (!cached) {
201
220
  const events = await usage.readEvents(now - 8 * DAY);
202
221
  windows = usage.buildWindows(base.utilization, events, now);
203
222
  const binding = usage.bindingWindow(windows);
204
223
  turnsLeft = binding && Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null;
205
224
  session = sessionSpend(events, sessionId);
206
- writeCache({ at: now, turnsLeft, session, sessionId });
225
+ writeCache(mergeCache(all, sessionId, { at: now, turnsLeft, session }, KEEP_SESSIONS));
207
226
  }
208
227
 
209
228
  const binding = usage.bindingWindow(windows) || windows[0];
@@ -243,6 +262,9 @@ module.exports = {
243
262
  summarise,
244
263
  briefText,
245
264
  settings,
265
+ pickCached,
266
+ mergeCache,
267
+ KEEP_SESSIONS,
246
268
  run,
247
269
  cacheFile,
248
270
  };
@@ -37,8 +37,43 @@ const RATES = {
37
37
  'claude-sonnet-4-6': { input: 3, output: 15 },
38
38
  'claude-haiku-4-5': { input: 1, output: 5 },
39
39
  };
40
+ // A model can ship before this table knows about it. Rather than refusing to
41
+ // price it, fall back to the average of the family it names. Averaging assumes
42
+ // nothing about which direction prices moved, unlike pinning to one release.
43
+ // An unrecognised family falls back to Opus rates on purpose: over-estimating
44
+ // cost understates headroom, and that is the safe direction for a budget.
45
+ const FAMILIES = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
40
46
  const FALLBACK_RATE = { input: 5, output: 25 };
41
47
 
48
+ function familyOf(model) {
49
+ const id = String(model || '').toLowerCase();
50
+ for (const family of FAMILIES) {
51
+ // Mythos is priced with Fable, so it counts as the same family.
52
+ if (id.indexOf(family) !== -1) return family === 'mythos' ? 'fable' : family;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function familyAverage(family, table) {
58
+ if (!family) return null;
59
+ const rates = table || RATES;
60
+ const members = Object.keys(rates).filter((id) => familyOf(id) === family);
61
+ if (!members.length) return null;
62
+
63
+ let input = 0;
64
+ let output = 0;
65
+ for (const id of members) {
66
+ input += rates[id].input;
67
+ output += rates[id].output;
68
+ }
69
+ return { input: input / members.length, output: output / members.length };
70
+ }
71
+
72
+ // Whether the price came from the table or from an assumption.
73
+ function isKnownModel(model) {
74
+ return Object.prototype.hasOwnProperty.call(RATES, String(model || '').toLowerCase());
75
+ }
76
+
42
77
  // Cache traffic is priced as a multiple of the input rate.
43
78
  const CACHE_WRITE_5M = 1.25;
44
79
  const CACHE_WRITE_1H = 2;
@@ -129,11 +164,7 @@ function readJson(file) {
129
164
  function rateFor(model) {
130
165
  const id = String(model || '').toLowerCase();
131
166
  if (RATES[id]) return RATES[id];
132
- if (id.includes('fable') || id.includes('mythos')) return RATES['claude-fable-5'];
133
- if (id.includes('opus')) return RATES['claude-opus-5'];
134
- if (id.includes('sonnet')) return RATES['claude-sonnet-5'];
135
- if (id.includes('haiku')) return RATES['claude-haiku-4-5'];
136
- return FALLBACK_RATE;
167
+ return familyAverage(familyOf(id)) || FALLBACK_RATE;
137
168
  }
138
169
 
139
170
  // Cost of one assistant turn, in USD, from its usage record.
@@ -300,6 +331,7 @@ function byModel(events) {
300
331
  if (!rows.has(id)) {
301
332
  rows.set(id, {
302
333
  model: id,
334
+ estimated: !isKnownModel(id),
303
335
  turns: 0,
304
336
  tokens: 0,
305
337
  cost: 0,
@@ -829,12 +861,16 @@ function render(data) {
829
861
  );
830
862
  for (const row of data.models) {
831
863
  lines.push(
832
- ' ' + pad(' ' + row.model, 24) + padLeft(row.turns, 7) +
864
+ ' ' + pad(' ' + row.model + (row.estimated ? ' *' : ''), 24) +
865
+ padLeft(row.turns, 7) +
833
866
  padLeft(formatTokens(row.tokens), 10) +
834
867
  padLeft(formatTokens(row.parts.output), 9) +
835
868
  padLeft(Math.round(row.share * 100) + '%', 8)
836
869
  );
837
870
  }
871
+ if (data.models.some((row) => row.estimated)) {
872
+ lines.push(' * no published rate for this one yet, priced at the family average');
873
+ }
838
874
  if (data.tokens) {
839
875
  lines.push(
840
876
  ' Tokens input ' + formatTokens(data.tokens.input) +
@@ -1020,6 +1056,9 @@ module.exports = {
1020
1056
  RATES,
1021
1057
  WINDOWS,
1022
1058
  rateFor,
1059
+ familyOf,
1060
+ familyAverage,
1061
+ isKnownModel,
1023
1062
  costOf,
1024
1063
  tokensOf,
1025
1064
  eventFrom,