claude-usage-limits 1.0.0 → 1.1.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/README.md +81 -4
- package/bin/cli.js +1 -0
- package/hooks/hooks.json +16 -0
- package/package.json +4 -2
- package/skills/usage-limits/SKILL.md +41 -0
- package/skills/usage-limits/scripts/brief.js +248 -0
- package/skills/usage-limits/scripts/usage.js +134 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.1.0",
|
|
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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# usage-limits
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/claude-usage-limits)
|
|
4
|
+
|
|
3
5
|
A Claude Code plugin that reads how much of your usage limit is left and plans
|
|
4
6
|
the work to fit inside it.
|
|
5
7
|
|
|
@@ -71,7 +73,9 @@ npx claude-usage-limits --status
|
|
|
71
73
|
npx claude-usage-limits lowpower on
|
|
72
74
|
```
|
|
73
75
|
|
|
74
|
-
That runs the same code as the plugin
|
|
76
|
+
That runs the same code as the plugin, from
|
|
77
|
+
[claude-usage-limits](https://www.npmjs.com/package/claude-usage-limits) on
|
|
78
|
+
npm. Node 18 or newer.
|
|
75
79
|
|
|
76
80
|
To have Claude read the numbers and plan against them, install it properly.
|
|
77
81
|
|
|
@@ -136,6 +140,18 @@ The skill also requires Claude to say up front when a job will not fit in what
|
|
|
136
140
|
is left, name what it is doing now, what it is leaving, and when the rest can
|
|
137
141
|
happen, rather than starting and stopping halfway through an edit.
|
|
138
142
|
|
|
143
|
+
## Releasing
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
npm version patch
|
|
147
|
+
git push --follow-tags
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Pushing the tag runs the tests and publishes to npm. That goes through npm's
|
|
151
|
+
trusted publishing over OIDC, so there is no publish token stored in the repo
|
|
152
|
+
or in CI. `npm version` also syncs the version in the plugin manifest, so the
|
|
153
|
+
marketplace and the npm package never disagree about which release is current.
|
|
154
|
+
|
|
139
155
|
## Status line
|
|
140
156
|
|
|
141
157
|
For a permanent readout instead of asking, point Claude Code's status line at
|
|
@@ -161,6 +177,67 @@ it runs in about a tenth of a second and is safe on every redraw. Use the full
|
|
|
161
177
|
path rather than `~` if your shell does not expand it, and point it at the
|
|
162
178
|
plugin copy instead if that is how you installed it.
|
|
163
179
|
|
|
180
|
+
## It tells you where you stand, every time
|
|
181
|
+
|
|
182
|
+
Installed as a plugin, a hook measures the budget before each prompt and puts
|
|
183
|
+
one line into Claude's context:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
[usage-limits] 5-hour 47%, weekly 16%, about 75 turns of headroom, resets in
|
|
187
|
+
3h 52m, this session 229 turns, $64.16.
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Claude opens with it. When there is room that is a single line and it moves on:
|
|
191
|
+
|
|
192
|
+
> Weekly is at 16%, 5-hour at 47%, about 75 turns of headroom. This fits easily.
|
|
193
|
+
|
|
194
|
+
When there is not, the line becomes a plan rather than a status:
|
|
195
|
+
|
|
196
|
+
> The weekly window has about 22 turns left. That covers the parser change and
|
|
197
|
+
> its tests, but not the migration or the docs pass, so I will do the first two
|
|
198
|
+
> and leave the rest for after the reset at 09:00.
|
|
199
|
+
|
|
200
|
+
The wording changes with the pressure, not only the numbers. The trigger worth
|
|
201
|
+
explaining is pace: two days into a week you should be near 29 percent spent, so
|
|
202
|
+
60 percent means you will not last the week, and that is worth hearing at 60
|
|
203
|
+
rather than at 85.
|
|
204
|
+
|
|
205
|
+
It has to be cheap, because it runs on every prompt. The percentages come from
|
|
206
|
+
one small file. The transcript scan behind "turns of headroom" is cached for a
|
|
207
|
+
minute, so it costs about 400ms cold and 120ms warm.
|
|
208
|
+
|
|
209
|
+
| Variable | Default | Effect |
|
|
210
|
+
| --- | --- | --- |
|
|
211
|
+
| `USAGE_LIMITS_BRIEF` | on | Set to `off` to turn the line off entirely. |
|
|
212
|
+
| `USAGE_LIMITS_NEAR` | 80 | Percent used that always counts as tight. |
|
|
213
|
+
| `USAGE_LIMITS_FLOOR` | 40 | Below this, pace is ignored. |
|
|
214
|
+
| `USAGE_LIMITS_AHEAD` | 15 | Points ahead of pace that count as burning fast. |
|
|
215
|
+
| `USAGE_LIMITS_CACHE` | 60 | Seconds the measured half stays good for. |
|
|
216
|
+
|
|
217
|
+
## What would this job cost
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
node skills/usage-limits/scripts/usage.js --forecast 15
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
Forecast for 15 turns
|
|
225
|
+
|
|
226
|
+
Window Would cost Leaves Verdict
|
|
227
|
+
5-hour 7.4% to 8.4% 45% fits
|
|
228
|
+
weekly 0.8% to 0.9% 83% fits
|
|
229
|
+
|
|
230
|
+
Priced from 64 recent turns: $0.296 typical, $0.333 at the expensive end.
|
|
231
|
+
|
|
232
|
+
There is room for this. No need to work around the limit.
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
It prices turns at what turns have really cost on your account, and gives a
|
|
236
|
+
range rather than one number, because a turn that reads three files costs many
|
|
237
|
+
times one that answers from context. The upper end is the honest one for a long
|
|
238
|
+
run, since turns get dearer as the context grows.
|
|
239
|
+
|
|
240
|
+
|
|
164
241
|
## Plans
|
|
165
242
|
|
|
166
243
|
It reads which plan you are on and adjusts what it tells you, because the
|
|
@@ -274,9 +351,9 @@ test/ node --test, no dependencies
|
|
|
274
351
|
node --test
|
|
275
352
|
```
|
|
276
353
|
|
|
277
|
-
|
|
278
|
-
the status line,
|
|
279
|
-
save/restore.
|
|
354
|
+
105 tests over the pricing, the window arithmetic, plan and credit detection,
|
|
355
|
+
the status line, the before-prompt line, job forecasting, per-project
|
|
356
|
+
attribution, the CLI, packaging, and the settings save/restore.
|
|
280
357
|
|
|
281
358
|
## Status
|
|
282
359
|
|
package/bin/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ const HELP = `claude-usage-limits - how much Claude Code usage is left, and whet
|
|
|
10
10
|
claude-usage-limits the report
|
|
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
|
+
claude-usage-limits --forecast 15 what a 15 turn job would cost
|
|
13
14
|
|
|
14
15
|
claude-usage-limits lowpower status show the current effort setting
|
|
15
16
|
claude-usage-limits lowpower on lower effortLevel, remembering the old value
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Tells Claude how much usage budget is left, but only when it is tight enough to change what can be done.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"UserPromptSubmit": [
|
|
5
|
+
{
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/usage-limits/scripts/brief.js\"",
|
|
10
|
+
"timeout": 10
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-limits",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"bin/",
|
|
32
32
|
"skills/",
|
|
33
33
|
"commands/",
|
|
34
|
+
"hooks/",
|
|
34
35
|
".claude-plugin/"
|
|
35
36
|
],
|
|
36
37
|
"engines": {
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
40
41
|
"test": "node --test",
|
|
41
|
-
"usage": "node bin/cli.js"
|
|
42
|
+
"usage": "node bin/cli.js",
|
|
43
|
+
"version": "node tools/sync-version.js && git add .claude-plugin/plugin.json"
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -92,6 +92,47 @@ yourself: Claude Code announces it and asks before drawing on credits, and
|
|
|
92
92
|
repeating that only adds noise. Just factor it into the plan, and take the
|
|
93
93
|
user's answer to that prompt as the decision.
|
|
94
94
|
|
|
95
|
+
## Opening with the budget
|
|
96
|
+
|
|
97
|
+
Start every reply with one line saying where the budget stands and whether what
|
|
98
|
+
was asked fits. The hook puts the numbers in front of you before the prompt, so
|
|
99
|
+
there is nothing to go and look up.
|
|
100
|
+
|
|
101
|
+
When there is room, one line, then get on with it:
|
|
102
|
+
|
|
103
|
+
> Weekly is at 16%, 5-hour at 47%, about 75 turns of headroom. This fits easily.
|
|
104
|
+
|
|
105
|
+
When it does not fit, that line becomes the plan:
|
|
106
|
+
|
|
107
|
+
> The weekly window has about 22 turns left. That covers the parser change and
|
|
108
|
+
> its tests, but not the migration or the docs pass, so I will do the first two
|
|
109
|
+
> and leave the rest for after the reset at 09:00.
|
|
110
|
+
|
|
111
|
+
What makes the second one useful is the split, not the percentage. "78% used"
|
|
112
|
+
is not something anyone can act on. "This fits, that does not, here is the
|
|
113
|
+
order" is.
|
|
114
|
+
|
|
115
|
+
Keep it to one line unless the work genuinely does not fit. The budget note is
|
|
116
|
+
a header, not a section, and it must never push the actual answer down the
|
|
117
|
+
page.
|
|
118
|
+
|
|
119
|
+
### Pricing the job before starting it
|
|
120
|
+
|
|
121
|
+
Size the work in turns using the table above, then price it:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
node scripts/usage.js --forecast 15
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
That converts turns into points of each window using what turns have really
|
|
128
|
+
cost on this account, and reports a range rather than a single number, because
|
|
129
|
+
a turn that reads three files costs many times one that answers from context.
|
|
130
|
+
The upper end is the honest one for a long run, since turns get dearer as the
|
|
131
|
+
context grows.
|
|
132
|
+
|
|
133
|
+
Reach for it whenever the answer to "will this finish" is not obvious.
|
|
134
|
+
|
|
135
|
+
|
|
95
136
|
## 4. Low power
|
|
96
137
|
|
|
97
138
|
Two halves, and the second one is the half that actually binds.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The hook entry point. Runs before each prompt is handled and puts one line
|
|
5
|
+
// into Claude's context saying where the budget stands, what this session has
|
|
6
|
+
// cost so far, and to open the reply with it.
|
|
7
|
+
//
|
|
8
|
+
// It runs on every prompt, so two things matter more than features: it has to
|
|
9
|
+
// be one line, and it has to be fast. The expensive half is cached for a
|
|
10
|
+
// minute, and the numbers that change every turn are the cheap ones.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const usage = require('./usage.js');
|
|
17
|
+
|
|
18
|
+
const SECOND = 1000;
|
|
19
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
// Close enough to the wall that the wording should change.
|
|
23
|
+
near: 80,
|
|
24
|
+
// Below this, pace is not worth worrying about.
|
|
25
|
+
floor: 40,
|
|
26
|
+
// Points of budget spent beyond the share of the window that has elapsed.
|
|
27
|
+
ahead: 15,
|
|
28
|
+
// How long the measured part stays good for. Prompts often arrive in
|
|
29
|
+
// bursts, and a transcript scan per prompt would be wasteful.
|
|
30
|
+
cacheSeconds: 60,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function configDir() {
|
|
34
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cacheFile() {
|
|
38
|
+
return path.join(configDir(), 'usage-limits-brief.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readCache() {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeCache(value) {
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
|
|
52
|
+
fs.writeFileSync(cacheFile(), JSON.stringify(value), 'utf8');
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function settings() {
|
|
59
|
+
const env = process.env;
|
|
60
|
+
const number = (value, fallback) => {
|
|
61
|
+
const parsed = Number(value);
|
|
62
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
near: number(env.USAGE_LIMITS_NEAR, DEFAULTS.near),
|
|
66
|
+
floor: number(env.USAGE_LIMITS_FLOOR, DEFAULTS.floor),
|
|
67
|
+
ahead: number(env.USAGE_LIMITS_AHEAD, DEFAULTS.ahead),
|
|
68
|
+
cacheSeconds: number(env.USAGE_LIMITS_CACHE, DEFAULTS.cacheSeconds),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// How far ahead of the clock the spending is. A weekly window two days in
|
|
73
|
+
// should be near 29 percent spent; 60 percent means it will not last.
|
|
74
|
+
function aheadOfPace(window, now) {
|
|
75
|
+
if (!window || !window.spanMs || !Number.isFinite(window.windowStart)) return null;
|
|
76
|
+
if (window.percentUsed === null) return null;
|
|
77
|
+
const elapsed = ((now - window.windowStart) / window.spanMs) * 100;
|
|
78
|
+
return window.percentUsed - Math.min(100, Math.max(0, elapsed));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Not whether to speak, which is always, but how hard to lean on it.
|
|
82
|
+
function pressure(window, now, config) {
|
|
83
|
+
if (!window || window.percentUsed === null || window.stale) return 'unknown';
|
|
84
|
+
if (window.verdict === 'exhausted') return 'gone';
|
|
85
|
+
if (window.verdict === 'runs-out') return 'tight';
|
|
86
|
+
if (window.percentUsed >= config.near) return 'tight';
|
|
87
|
+
|
|
88
|
+
const lead = aheadOfPace(window, now);
|
|
89
|
+
if (window.percentUsed >= config.floor && lead !== null && lead >= config.ahead) {
|
|
90
|
+
return 'tight';
|
|
91
|
+
}
|
|
92
|
+
return 'roomy';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The hook is handed JSON on stdin. The session id in it is what lets this
|
|
96
|
+
// report what the current session has cost rather than the whole window.
|
|
97
|
+
function readHookInput() {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
if (process.stdin.isTTY) return resolve(null);
|
|
100
|
+
|
|
101
|
+
let raw = '';
|
|
102
|
+
let settled = false;
|
|
103
|
+
const done = () => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
settled = true;
|
|
106
|
+
try {
|
|
107
|
+
resolve(raw ? JSON.parse(raw) : null);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
resolve(null);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// A hook must never hang the prompt waiting for input that is not coming.
|
|
114
|
+
const timer = setTimeout(done, 500);
|
|
115
|
+
if (timer.unref) timer.unref();
|
|
116
|
+
|
|
117
|
+
process.stdin.setEncoding('utf8');
|
|
118
|
+
process.stdin.on('data', (chunk) => {
|
|
119
|
+
raw += chunk;
|
|
120
|
+
});
|
|
121
|
+
process.stdin.on('end', done);
|
|
122
|
+
process.stdin.on('error', done);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sessionSpend(events, sessionId) {
|
|
127
|
+
if (!sessionId) return null;
|
|
128
|
+
let cost = 0;
|
|
129
|
+
let turns = 0;
|
|
130
|
+
for (const event of events) {
|
|
131
|
+
if (event.sessionId !== sessionId) continue;
|
|
132
|
+
cost += event.cost;
|
|
133
|
+
turns += 1;
|
|
134
|
+
}
|
|
135
|
+
return turns ? { turns, cost } : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function summarise(windows) {
|
|
139
|
+
return windows
|
|
140
|
+
.filter((window) => window.percentUsed !== null)
|
|
141
|
+
.map((window) =>
|
|
142
|
+
window.stale
|
|
143
|
+
? window.label + ' rolling over'
|
|
144
|
+
: window.label + ' ' + window.percentUsed + '%'
|
|
145
|
+
)
|
|
146
|
+
.join(', ');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function briefText(parts) {
|
|
150
|
+
const facts = [];
|
|
151
|
+
if (parts.windowSummary) facts.push(parts.windowSummary);
|
|
152
|
+
if (Number.isFinite(parts.turnsLeft)) {
|
|
153
|
+
facts.push('about ' + parts.turnsLeft + ' turns of headroom');
|
|
154
|
+
}
|
|
155
|
+
if (parts.resetsIn) facts.push('resets in ' + parts.resetsIn);
|
|
156
|
+
if (parts.session) {
|
|
157
|
+
facts.push(
|
|
158
|
+
'this session ' + parts.session.turns + ' turns, ' +
|
|
159
|
+
usage.formatUSD(parts.session.cost)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const head = '[usage-limits] ' + facts.join(', ') + '.';
|
|
164
|
+
|
|
165
|
+
const instruction =
|
|
166
|
+
parts.pressure === 'tight' || parts.pressure === 'gone'
|
|
167
|
+
? 'Open your reply with one line on where this leaves the budget, then say ' +
|
|
168
|
+
'what you will do now and what you will leave for after the reset. ' +
|
|
169
|
+
'Do not start work that clearly will not finish.'
|
|
170
|
+
: 'Open your reply with one short line stating this and confirming the ' +
|
|
171
|
+
'request fits, then get on with the work. Keep it to a single line.';
|
|
172
|
+
|
|
173
|
+
return head + '\n' + instruction;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function run(now, hookInput) {
|
|
177
|
+
if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
|
|
178
|
+
|
|
179
|
+
const config = settings();
|
|
180
|
+
const base = usage.collect(now);
|
|
181
|
+
if (!base.utilization) return '';
|
|
182
|
+
|
|
183
|
+
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
184
|
+
|
|
185
|
+
// The percentages are cheap: one small file, no transcripts.
|
|
186
|
+
const cheap = usage.buildWindows(base.utilization, [], now);
|
|
187
|
+
if (!cheap.length) return '';
|
|
188
|
+
|
|
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;
|
|
195
|
+
|
|
196
|
+
let turnsLeft = fresh ? cached.turnsLeft : null;
|
|
197
|
+
let session = fresh ? cached.session : null;
|
|
198
|
+
let windows = cheap;
|
|
199
|
+
|
|
200
|
+
if (!fresh) {
|
|
201
|
+
const events = await usage.readEvents(now - 8 * DAY);
|
|
202
|
+
windows = usage.buildWindows(base.utilization, events, now);
|
|
203
|
+
const binding = usage.bindingWindow(windows);
|
|
204
|
+
turnsLeft = binding && Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null;
|
|
205
|
+
session = sessionSpend(events, sessionId);
|
|
206
|
+
writeCache({ at: now, turnsLeft, session, sessionId });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const binding = usage.bindingWindow(windows) || windows[0];
|
|
210
|
+
|
|
211
|
+
return briefText({
|
|
212
|
+
windowSummary: summarise(windows),
|
|
213
|
+
turnsLeft,
|
|
214
|
+
resetsIn:
|
|
215
|
+
binding && !binding.stale && binding.msToReset !== null
|
|
216
|
+
? usage.formatDuration(binding.msToReset)
|
|
217
|
+
: null,
|
|
218
|
+
session,
|
|
219
|
+
pressure: pressure(binding, now, config),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (require.main === module) {
|
|
224
|
+
readHookInput()
|
|
225
|
+
.then((input) => run(Date.now(), input))
|
|
226
|
+
.then(
|
|
227
|
+
(text) => {
|
|
228
|
+
if (text) process.stdout.write(text + '\n');
|
|
229
|
+
process.exit(0);
|
|
230
|
+
},
|
|
231
|
+
() => {
|
|
232
|
+
// A hook that throws must not disrupt the prompt it runs before.
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
DEFAULTS,
|
|
240
|
+
aheadOfPace,
|
|
241
|
+
pressure,
|
|
242
|
+
sessionSpend,
|
|
243
|
+
summarise,
|
|
244
|
+
briefText,
|
|
245
|
+
settings,
|
|
246
|
+
run,
|
|
247
|
+
cacheFile,
|
|
248
|
+
};
|
|
@@ -217,6 +217,7 @@ function eventFrom(line, seen, project) {
|
|
|
217
217
|
tokens: tokensOf(entry.message.usage),
|
|
218
218
|
parts: tokenParts(entry.message.usage),
|
|
219
219
|
project: project || null,
|
|
220
|
+
sessionId: entry.sessionId || null,
|
|
220
221
|
};
|
|
221
222
|
}
|
|
222
223
|
|
|
@@ -396,6 +397,46 @@ function creditsFrom(utilization) {
|
|
|
396
397
|
};
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
// Turn cost is not a single number, it is a spread: a turn that reads three
|
|
401
|
+
// files costs many times one that answers from context. A median alone
|
|
402
|
+
// under-promises on the expensive half, so carry a high end too.
|
|
403
|
+
function costPercentiles(events) {
|
|
404
|
+
const costs = (events || [])
|
|
405
|
+
.map((event) => event.cost)
|
|
406
|
+
.filter((cost) => Number.isFinite(cost) && cost > 0)
|
|
407
|
+
.sort((a, b) => a - b);
|
|
408
|
+
if (!costs.length) return null;
|
|
409
|
+
|
|
410
|
+
const at = (fraction) => costs[Math.min(costs.length - 1, Math.floor(fraction * costs.length))];
|
|
411
|
+
return { median: at(0.5), high: at(0.8), sample: costs.length };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// What a job of this many turns would take out of one window.
|
|
415
|
+
function forecastWindow(window, turns, rates) {
|
|
416
|
+
if (!window || !rates || !window.usdPerPercent || window.stale) return null;
|
|
417
|
+
if (!Number.isFinite(turns) || turns <= 0) return null;
|
|
418
|
+
|
|
419
|
+
const usdLow = turns * rates.median;
|
|
420
|
+
const usdHigh = turns * rates.high;
|
|
421
|
+
const percentLow = usdLow / window.usdPerPercent;
|
|
422
|
+
const percentHigh = usdHigh / window.usdPerPercent;
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
key: window.key,
|
|
426
|
+
label: window.label,
|
|
427
|
+
turns,
|
|
428
|
+
usdLow,
|
|
429
|
+
usdHigh,
|
|
430
|
+
percentLow,
|
|
431
|
+
percentHigh,
|
|
432
|
+
// The pessimistic cost is what decides whether it fits, so the room left
|
|
433
|
+
// over is measured against that.
|
|
434
|
+
leaves: window.percentLeft - percentHigh,
|
|
435
|
+
fits: percentHigh <= window.percentLeft,
|
|
436
|
+
tight: percentHigh > window.percentLeft * 0.75 && percentHigh <= window.percentLeft,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
399
440
|
// Everything the report needs about one limit window.
|
|
400
441
|
function buildWindow(spec, snapshot, events, now) {
|
|
401
442
|
const percent =
|
|
@@ -420,6 +461,7 @@ function buildWindow(spec, snapshot, events, now) {
|
|
|
420
461
|
resetsAt: hasReset ? resetsAt : null,
|
|
421
462
|
msToReset: hasReset ? resetsAt - now : null,
|
|
422
463
|
windowStart: start,
|
|
464
|
+
spanMs: spec.span,
|
|
423
465
|
spentUSD: spent.cost,
|
|
424
466
|
spentTokens: spent.tokens,
|
|
425
467
|
turns: spent.turns,
|
|
@@ -626,6 +668,7 @@ async function report(now) {
|
|
|
626
668
|
windows,
|
|
627
669
|
binding,
|
|
628
670
|
credits: creditsFrom(base.utilization),
|
|
671
|
+
rates: costPercentiles(recentEvents.length >= 5 ? recentEvents : scoped),
|
|
629
672
|
resumeAt: binding ? binding.resetsAt : null,
|
|
630
673
|
models: byModel(scoped),
|
|
631
674
|
projects: byProject(scoped),
|
|
@@ -861,6 +904,79 @@ function render(data) {
|
|
|
861
904
|
return lines.join('\n');
|
|
862
905
|
}
|
|
863
906
|
|
|
907
|
+
function formatPercent(value) {
|
|
908
|
+
if (!Number.isFinite(value)) return '-';
|
|
909
|
+
if (value >= 10) return Math.round(value) + '%';
|
|
910
|
+
return value.toFixed(1) + '%';
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function renderForecast(data, turns) {
|
|
914
|
+
const lines = [];
|
|
915
|
+
lines.push('Forecast for ' + turns + ' turns');
|
|
916
|
+
lines.push('');
|
|
917
|
+
|
|
918
|
+
if (!Number.isFinite(turns) || turns <= 0) {
|
|
919
|
+
lines.push(' Give a number of turns, for example --forecast 15.');
|
|
920
|
+
return lines.join('\n');
|
|
921
|
+
}
|
|
922
|
+
if (!data.rates) {
|
|
923
|
+
lines.push(' Nothing recent to price this against yet. Do some work in this');
|
|
924
|
+
lines.push(' session first, then ask again.');
|
|
925
|
+
return lines.join('\n');
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const rows = data.windows
|
|
929
|
+
.map((window) => forecastWindow(window, turns, data.rates))
|
|
930
|
+
.filter(Boolean);
|
|
931
|
+
|
|
932
|
+
if (!rows.length) {
|
|
933
|
+
lines.push(' No window has enough measured spend to price a forecast against.');
|
|
934
|
+
return lines.join('\n');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
lines.push(
|
|
938
|
+
' ' + pad('Window', 15) + padLeft('Would cost', 18) + padLeft('Leaves', 10) + ' Verdict'
|
|
939
|
+
);
|
|
940
|
+
for (const row of rows) {
|
|
941
|
+
const verdict = row.fits ? (row.tight ? 'fits, barely' : 'fits') : 'does not fit';
|
|
942
|
+
lines.push(
|
|
943
|
+
' ' + pad(row.label, 15) +
|
|
944
|
+
padLeft(formatPercent(row.percentLow) + ' to ' + formatPercent(row.percentHigh), 18) +
|
|
945
|
+
padLeft(formatPercent(Math.max(0, row.leaves)), 10) +
|
|
946
|
+
' ' + verdict
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
lines.push('');
|
|
950
|
+
lines.push(
|
|
951
|
+
' Priced from ' + data.rates.sample + ' recent turns: ' +
|
|
952
|
+
formatUSD(data.rates.median) + ' typical, ' + formatUSD(data.rates.high) +
|
|
953
|
+
' at the expensive end.'
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
const blocked = rows.filter((row) => !row.fits);
|
|
957
|
+
const tight = rows.filter((row) => row.fits && row.tight);
|
|
958
|
+
lines.push('');
|
|
959
|
+
if (blocked.length) {
|
|
960
|
+
lines.push(
|
|
961
|
+
' The ' + blocked[0].label + ' window does not cover this. Cut it down or ' +
|
|
962
|
+
'split it at a clean boundary rather than starting and getting cut off.'
|
|
963
|
+
);
|
|
964
|
+
} else if (tight.length) {
|
|
965
|
+
lines.push(
|
|
966
|
+
' It fits, but only if nothing goes wrong. Order the work so the valuable ' +
|
|
967
|
+
'part lands first.'
|
|
968
|
+
);
|
|
969
|
+
} else {
|
|
970
|
+
lines.push(' There is room for this. No need to work around the limit.');
|
|
971
|
+
}
|
|
972
|
+
lines.push(
|
|
973
|
+
' Turns get dearer as context grows, so the higher number is the honest one ' +
|
|
974
|
+
'for a long run.'
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
return lines.join('\n');
|
|
978
|
+
}
|
|
979
|
+
|
|
864
980
|
async function main(argv) {
|
|
865
981
|
// The status line runs on every redraw, so it must not scan transcripts.
|
|
866
982
|
if (argv.indexOf('--status') !== -1) {
|
|
@@ -870,6 +986,20 @@ async function main(argv) {
|
|
|
870
986
|
|
|
871
987
|
const wantsJson = argv.indexOf('--json') !== -1;
|
|
872
988
|
const data = await report(Date.now());
|
|
989
|
+
|
|
990
|
+
const forecastAt = argv.indexOf('--forecast');
|
|
991
|
+
if (forecastAt !== -1) {
|
|
992
|
+
const turns = Number(argv[forecastAt + 1]);
|
|
993
|
+
if (wantsJson) {
|
|
994
|
+
const rows = data.windows
|
|
995
|
+
.map((window) => forecastWindow(window, turns, data.rates))
|
|
996
|
+
.filter(Boolean);
|
|
997
|
+
process.stdout.write(JSON.stringify({ turns, rates: data.rates, windows: rows }, null, 2) + '\n');
|
|
998
|
+
} else {
|
|
999
|
+
process.stdout.write(renderForecast(data, turns) + '\n');
|
|
1000
|
+
}
|
|
1001
|
+
return 0;
|
|
1002
|
+
}
|
|
873
1003
|
if (wantsJson) {
|
|
874
1004
|
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
875
1005
|
} else {
|
|
@@ -893,6 +1023,7 @@ module.exports = {
|
|
|
893
1023
|
costOf,
|
|
894
1024
|
tokensOf,
|
|
895
1025
|
eventFrom,
|
|
1026
|
+
readEvents,
|
|
896
1027
|
buildWindow,
|
|
897
1028
|
buildWindows,
|
|
898
1029
|
bindingWindow,
|
|
@@ -905,6 +1036,9 @@ module.exports = {
|
|
|
905
1036
|
report,
|
|
906
1037
|
collect,
|
|
907
1038
|
detectPlan,
|
|
1039
|
+
costPercentiles,
|
|
1040
|
+
forecastWindow,
|
|
1041
|
+
renderForecast,
|
|
908
1042
|
creditsFrom,
|
|
909
1043
|
formatClock,
|
|
910
1044
|
statusLine,
|