claude-usage-limits 1.1.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +11 -4
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +2 -1
- package/skills/usage-limits/references/how-it-works.md +15 -3
- package/skills/usage-limits/scripts/brief.js +36 -14
- package/skills/usage-limits/scripts/usage.js +45 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.1.
|
|
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
|
@@ -349,6 +349,9 @@ Good enough to plan with, not a bill. The honest caveats:
|
|
|
349
349
|
a percentage of the limit. On a subscription plan you are not billed them.
|
|
350
350
|
- Turns left assumes the next turns look like the last hour's. A debugging
|
|
351
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.
|
|
352
355
|
- The cache only refreshes when Claude Code talks to the API, so after an idle
|
|
353
356
|
spell a window can sit past its own reset time. When that happens the report
|
|
354
357
|
says `stale` and the status line says `rolling` rather than reporting a
|
|
@@ -362,9 +365,13 @@ names, the formulas, and the rest of it.
|
|
|
362
365
|
```
|
|
363
366
|
.claude-plugin/plugin.json plugin manifest
|
|
364
367
|
.claude-plugin/marketplace.json lets the repo serve itself
|
|
365
|
-
skills/usage-limits/SKILL.md
|
|
366
|
-
skills/usage-limits/scripts/
|
|
367
|
-
skills/usage-limits/references/
|
|
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
|
|
368
375
|
test/ node --test, no dependencies
|
|
369
376
|
```
|
|
370
377
|
|
|
@@ -374,7 +381,7 @@ test/ node --test, no dependencies
|
|
|
374
381
|
node --test
|
|
375
382
|
```
|
|
376
383
|
|
|
377
|
-
|
|
384
|
+
119 tests over the pricing, the window arithmetic, plan and credit detection,
|
|
378
385
|
the status line, the before-prompt line, job forecasting, per-project
|
|
379
386
|
attribution, the CLI, packaging, and the settings save/restore.
|
|
380
387
|
|
package/package.json
CHANGED
|
@@ -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
|
|
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.
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
|
|
49
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
44
50
|
} catch (err) {
|
|
45
|
-
return
|
|
51
|
+
return {};
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
function
|
|
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(
|
|
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
|
|
190
|
-
const
|
|
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 =
|
|
197
|
-
let session =
|
|
215
|
+
let turnsLeft = cached ? cached.turnsLeft : null;
|
|
216
|
+
let session = cached ? cached.session : null;
|
|
198
217
|
let windows = cheap;
|
|
199
218
|
|
|
200
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|