claude-usage-limits 1.7.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +118 -12
- package/bin/cli.js +3 -0
- package/commands/session.md +13 -0
- package/hooks/hooks.json +23 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +69 -1
- package/skills/usage-limits/references/how-it-works.md +15 -0
- package/skills/usage-limits/references/tactics.md +8 -1
- package/skills/usage-limits/scripts/brief.js +147 -11
- package/skills/usage-limits/scripts/lowpower.js +9 -0
- package/skills/usage-limits/scripts/pulse.js +6 -4
- package/skills/usage-limits/scripts/recommend.js +295 -0
- package/skills/usage-limits/scripts/sessionend.js +47 -0
- package/skills/usage-limits/scripts/stop.js +55 -0
- package/skills/usage-limits/scripts/tally.js +378 -0
- package/skills/usage-limits/scripts/usage.js +488 -70
|
@@ -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
|
|
|
@@ -129,10 +129,12 @@ async function run(now, hookInput) {
|
|
|
129
129
|
const binding = data.binding;
|
|
130
130
|
if (!binding) return '';
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
// The same count and the same split as the brief, so the two lines never
|
|
133
|
+
// disagree about how many sessions there are or how much of the budget is
|
|
134
|
+
// this one's.
|
|
135
|
+
const { active, share } = brief.activeShare(data.sessions, brief.readCache(), now, sessionId);
|
|
134
136
|
const turnsLeft = Number.isFinite(binding.turnsLeft)
|
|
135
|
-
?
|
|
137
|
+
? active > 1
|
|
136
138
|
? Math.max(1, Math.round(binding.turnsLeft * share))
|
|
137
139
|
: binding.turnsLeft
|
|
138
140
|
: null;
|
|
@@ -156,7 +158,7 @@ async function run(now, hookInput) {
|
|
|
156
158
|
Number.isFinite(binding.headroomMs) && binding.headroomMs <= runwayMs
|
|
157
159
|
? usage.formatDuration(binding.headroomMs)
|
|
158
160
|
: null,
|
|
159
|
-
sessions:
|
|
161
|
+
sessions: active,
|
|
160
162
|
pressure,
|
|
161
163
|
});
|
|
162
164
|
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The closing line.
|
|
5
|
+
//
|
|
6
|
+
// Runs as the SessionEnd hook. Brings the session's total up to date one last
|
|
7
|
+
// time, marks it closed, and prints one line saying how long it ran and what
|
|
8
|
+
// it cost. SessionEnd shows plain stdout to the user and gives hooks a short
|
|
9
|
+
// shared budget, which the incremental read fits inside comfortably.
|
|
10
|
+
|
|
11
|
+
const usage = require('./usage.js');
|
|
12
|
+
const host = require('./host.js');
|
|
13
|
+
const tally = require('./tally.js');
|
|
14
|
+
|
|
15
|
+
async function run(now, hookInput) {
|
|
16
|
+
if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
|
|
17
|
+
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
18
|
+
|
|
19
|
+
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
20
|
+
const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
|
|
21
|
+
if (!sessionId || !transcript) return '';
|
|
22
|
+
|
|
23
|
+
const all = tally.readState();
|
|
24
|
+
const { session } = tally.update(all, sessionId, transcript, now, { cwd: hookInput.cwd || null });
|
|
25
|
+
session.endedAt = now;
|
|
26
|
+
session.reason = typeof hookInput.reason === 'string' ? hookInput.reason : null;
|
|
27
|
+
tally.writeState(tally.trim(all));
|
|
28
|
+
|
|
29
|
+
return tally.formatClosed(session, now);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (require.main === module) {
|
|
33
|
+
tally
|
|
34
|
+
.readHookInput()
|
|
35
|
+
.then((input) => run(Date.now(), input))
|
|
36
|
+
.then(
|
|
37
|
+
(text) => {
|
|
38
|
+
if (text) process.stdout.write(text + '\n');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
},
|
|
41
|
+
() => {
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { run };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The end-of-reply tally.
|
|
5
|
+
//
|
|
6
|
+
// Runs as the Stop hook, after Claude has finished a reply, and puts one line
|
|
7
|
+
// in front of the user saying what that reply cost and what the session has
|
|
8
|
+
// cost so far. It costs the model nothing: the line goes to the person, not
|
|
9
|
+
// into the context, and the numbers come from bytes of the transcript that
|
|
10
|
+
// have already been written.
|
|
11
|
+
//
|
|
12
|
+
// It must never exit with code 2. On this event that would stop Claude from
|
|
13
|
+
// stopping.
|
|
14
|
+
|
|
15
|
+
const usage = require('./usage.js');
|
|
16
|
+
const host = require('./host.js');
|
|
17
|
+
const tally = require('./tally.js');
|
|
18
|
+
|
|
19
|
+
async function run(now, hookInput) {
|
|
20
|
+
if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
|
|
21
|
+
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
22
|
+
|
|
23
|
+
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
24
|
+
const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
|
|
25
|
+
if (!sessionId || !transcript) return '';
|
|
26
|
+
|
|
27
|
+
const all = tally.readState();
|
|
28
|
+
const { session, delta, created } = tally.update(all, sessionId, transcript, now, {
|
|
29
|
+
cwd: hookInput.cwd || null,
|
|
30
|
+
});
|
|
31
|
+
tally.writeState(tally.trim(all));
|
|
32
|
+
|
|
33
|
+
// The first time a session is seen, everything read is history rather than
|
|
34
|
+
// the reply that just finished, so only the total is shown.
|
|
35
|
+
return JSON.stringify({
|
|
36
|
+
systemMessage: tally.formatTally(session, created ? null : delta, tally.pricing(now)),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (require.main === module) {
|
|
41
|
+
tally
|
|
42
|
+
.readHookInput()
|
|
43
|
+
.then((input) => run(Date.now(), input))
|
|
44
|
+
.then(
|
|
45
|
+
(text) => {
|
|
46
|
+
if (text) process.stdout.write(text + '\n');
|
|
47
|
+
process.exit(0);
|
|
48
|
+
},
|
|
49
|
+
() => {
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { run };
|