claude-usage-limits 1.7.1 → 1.8.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 +76 -12
- package/bin/cli.js +2 -0
- package/commands/session.md +13 -0
- package/hooks/hooks.json +23 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +29 -1
- package/skills/usage-limits/references/how-it-works.md +15 -0
- package/skills/usage-limits/references/tactics.md +1 -1
- package/skills/usage-limits/scripts/brief.js +144 -10
- package/skills/usage-limits/scripts/pulse.js +6 -4
- 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 +425 -62
|
@@ -55,7 +55,7 @@ const RATES = {
|
|
|
55
55
|
'claude-opus-4-8': { input: 5, output: 25 },
|
|
56
56
|
'claude-opus-4-7': { input: 5, output: 25 },
|
|
57
57
|
'claude-opus-4-6': { input: 5, output: 25 },
|
|
58
|
-
'claude-sonnet-5': { input:
|
|
58
|
+
'claude-sonnet-5': { input: 2, output: 10 },
|
|
59
59
|
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
60
60
|
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
61
61
|
};
|
|
@@ -296,6 +296,10 @@ function eventFrom(line, seen, project) {
|
|
|
296
296
|
};
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
// Interrupts and client-side errors are written as assistant messages from
|
|
300
|
+
// a model called <synthetic>, with a usage block of zeros. Not a call.
|
|
301
|
+
if (entry.message.model === '<synthetic>') return null;
|
|
302
|
+
|
|
299
303
|
// A resumed or forked session repeats earlier turns in a new file.
|
|
300
304
|
const id = (entry.message.id || '') + '|' + (entry.requestId || '');
|
|
301
305
|
if (id !== '|' && seen) {
|
|
@@ -303,23 +307,76 @@ function eventFrom(line, seen, project) {
|
|
|
303
307
|
seen.add(id);
|
|
304
308
|
}
|
|
305
309
|
|
|
310
|
+
const parts = tokenParts(entry.message.usage);
|
|
306
311
|
return {
|
|
307
312
|
at,
|
|
308
313
|
model: entry.message.model || '',
|
|
309
314
|
effort: entry.effort || null,
|
|
310
315
|
cost: costOf(entry.message.usage, entry.message.model),
|
|
311
316
|
tokens: tokensOf(entry.message.usage),
|
|
312
|
-
parts
|
|
317
|
+
parts,
|
|
318
|
+
// What the model was shown on this call: everything except what it wrote.
|
|
319
|
+
// It is re-sent on every later call, so it is the recurring cost of the
|
|
320
|
+
// session, and the one number that says how bloated the context has got.
|
|
321
|
+
context: parts.input + parts.cacheRead + parts.cacheWrite,
|
|
313
322
|
project: project || null,
|
|
314
323
|
sessionId: entry.sessionId || null,
|
|
324
|
+
// A subagent's turns are written under the parent session with these set.
|
|
325
|
+
sidechain: Boolean(entry.isSidechain || entry.agentId),
|
|
315
326
|
};
|
|
316
327
|
}
|
|
317
328
|
|
|
329
|
+
// Whether a transcript line is a prompt the user typed. Tool results are also
|
|
330
|
+
// written as user messages, and so are internal notes marked isMeta; neither
|
|
331
|
+
// is something a person asked for.
|
|
332
|
+
function promptFrom(line) {
|
|
333
|
+
if (line.indexOf('"user"') === -1) return false;
|
|
334
|
+
let entry;
|
|
335
|
+
try {
|
|
336
|
+
entry = JSON.parse(line);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (!entry || entry.type !== 'user' || entry.isMeta || !entry.message) return false;
|
|
341
|
+
const content = entry.message.content;
|
|
342
|
+
if (typeof content === 'string') return content.length > 0;
|
|
343
|
+
if (!Array.isArray(content)) return false;
|
|
344
|
+
let typed = false;
|
|
345
|
+
for (const block of content) {
|
|
346
|
+
if (!block) continue;
|
|
347
|
+
if (block.type === 'tool_result') return false;
|
|
348
|
+
if (block.type === 'text') typed = true;
|
|
349
|
+
}
|
|
350
|
+
return typed;
|
|
351
|
+
}
|
|
352
|
+
|
|
318
353
|
async function readEvents(since) {
|
|
319
354
|
if (isCodex()) return codex.readEvents(since);
|
|
320
355
|
return readClaudeEvents(since);
|
|
321
356
|
}
|
|
322
357
|
|
|
358
|
+
// A file last touched before the window opened holds nothing useful.
|
|
359
|
+
function fresh(file, since) {
|
|
360
|
+
try {
|
|
361
|
+
return fs.statSync(file).mtimeMs >= since;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function freshFiles(dir, since) {
|
|
368
|
+
let names;
|
|
369
|
+
try {
|
|
370
|
+
names = fs.readdirSync(dir);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
return names
|
|
375
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
376
|
+
.map((name) => path.join(dir, name))
|
|
377
|
+
.filter((file) => fresh(file, since));
|
|
378
|
+
}
|
|
379
|
+
|
|
323
380
|
async function readClaudeEvents(since) {
|
|
324
381
|
const root = path.join(configDir(), 'projects');
|
|
325
382
|
let dirs = [];
|
|
@@ -333,21 +390,25 @@ async function readClaudeEvents(since) {
|
|
|
333
390
|
for (const dir of dirs) {
|
|
334
391
|
if (!dir.isDirectory()) continue;
|
|
335
392
|
const full = path.join(root, dir.name);
|
|
336
|
-
let
|
|
393
|
+
let entries = [];
|
|
337
394
|
try {
|
|
338
|
-
|
|
395
|
+
entries = fs.readdirSync(full, { withFileTypes: true });
|
|
339
396
|
} catch (err) {
|
|
340
397
|
continue;
|
|
341
398
|
}
|
|
342
|
-
for (const
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
399
|
+
for (const entry of entries) {
|
|
400
|
+
if (entry.isDirectory()) {
|
|
401
|
+
// A session's subagents write their transcripts under
|
|
402
|
+
// <project>/<session id>/subagents/. Same budget, different file, and
|
|
403
|
+
// for a long time an Explore or Plan agent's whole spend went unseen.
|
|
404
|
+
for (const file of freshFiles(path.join(full, entry.name, 'subagents'), since)) {
|
|
405
|
+
files.push({ file, project: dir.name });
|
|
406
|
+
}
|
|
349
407
|
continue;
|
|
350
408
|
}
|
|
409
|
+
if (!entry.name.endsWith('.jsonl')) continue;
|
|
410
|
+
const file = path.join(full, entry.name);
|
|
411
|
+
if (!fresh(file, since)) continue;
|
|
351
412
|
files.push({ file, project: dir.name });
|
|
352
413
|
}
|
|
353
414
|
}
|
|
@@ -374,13 +435,20 @@ async function readClaudeEvents(since) {
|
|
|
374
435
|
return events;
|
|
375
436
|
}
|
|
376
437
|
|
|
438
|
+
// A turn is one main-thread call, the unit the headroom is planned in. A
|
|
439
|
+
// subagent's calls spend the same budget, so they count in the money and the
|
|
440
|
+
// tokens, and are counted apart so they never inflate the turn figures.
|
|
377
441
|
function totals(events) {
|
|
378
442
|
let cost = 0;
|
|
379
443
|
let tokens = 0;
|
|
444
|
+
let turns = 0;
|
|
445
|
+
let subagentTurns = 0;
|
|
380
446
|
const parts = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 };
|
|
381
447
|
for (const event of events) {
|
|
382
448
|
cost += event.cost;
|
|
383
449
|
tokens += event.tokens;
|
|
450
|
+
if (event.sidechain) subagentTurns += 1;
|
|
451
|
+
else turns += 1;
|
|
384
452
|
if (event.parts) {
|
|
385
453
|
parts.input += event.parts.input;
|
|
386
454
|
parts.cacheWrite += event.parts.cacheWrite;
|
|
@@ -389,7 +457,11 @@ function totals(events) {
|
|
|
389
457
|
parts.reasoning += event.parts.reasoning || 0;
|
|
390
458
|
}
|
|
391
459
|
}
|
|
392
|
-
return { cost, tokens, turns
|
|
460
|
+
return { cost, tokens, turns, subagentTurns, parts };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function mainThread(events) {
|
|
464
|
+
return (events || []).filter((event) => !event.sidechain);
|
|
393
465
|
}
|
|
394
466
|
|
|
395
467
|
// What each model actually cost, dearest first.
|
|
@@ -437,8 +509,9 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
|
|
|
437
509
|
|
|
438
510
|
// What a turn costs is a fact about how you work, not about which budget it
|
|
439
511
|
// is being measured against, so a thin window borrows from a wider sample
|
|
440
|
-
// rather than inventing a figure from two turns.
|
|
441
|
-
|
|
512
|
+
// rather than inventing a figure from two turns. Subagent calls are left
|
|
513
|
+
// out: they are small and many, and would make a turn look cheap.
|
|
514
|
+
const tiers = [mainThread(recentEvents), mainThread(windowEvents), mainThread(allEvents)];
|
|
442
515
|
let pool = [];
|
|
443
516
|
for (const tier of tiers) {
|
|
444
517
|
if (tier && tier.length >= floor) {
|
|
@@ -578,7 +651,7 @@ function creditsFrom(utilization) {
|
|
|
578
651
|
// files costs many times one that answers from context. A median alone
|
|
579
652
|
// under-promises on the expensive half, so carry a high end too.
|
|
580
653
|
function costPercentiles(events) {
|
|
581
|
-
const costs = (events
|
|
654
|
+
const costs = mainThread(events)
|
|
582
655
|
.map((event) => event.cost)
|
|
583
656
|
.filter((cost) => Number.isFinite(cost) && cost > 0)
|
|
584
657
|
.sort((a, b) => a - b);
|
|
@@ -640,11 +713,25 @@ function activeSessions(events, now, windowMs) {
|
|
|
640
713
|
|
|
641
714
|
// The slice of the shared budget this session is actually getting. With
|
|
642
715
|
// another session spending half of it, only half those turns are yours.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
716
|
+
//
|
|
717
|
+
// Past spend says how the budget has been going, not how it will go. A
|
|
718
|
+
// session that has only just started has almost none of it, and dividing its
|
|
719
|
+
// headroom by that share once turned 208 turns into "about 6 turns left" two
|
|
720
|
+
// tool calls into a session at 13 per cent used. So an equal split is the
|
|
721
|
+
// floor: a measured share can raise it, never lower it. `activeCount` is how
|
|
722
|
+
// many sessions are open, which can exceed how many have spent yet; the split
|
|
723
|
+
// is among all of them.
|
|
724
|
+
function shareOf(sessions, sessionId, activeCount) {
|
|
725
|
+
const spent = sessions || [];
|
|
726
|
+
const n = Math.max(spent.length, Number.isFinite(activeCount) ? activeCount : 0);
|
|
727
|
+
if (n < 2) return 1;
|
|
728
|
+
const equal = 1 / n;
|
|
729
|
+
// One session's spend is not a comparison. Until a second one has spent,
|
|
730
|
+
// being the only one on record says nothing about who owns the budget.
|
|
731
|
+
if (spent.length < 2) return equal;
|
|
732
|
+
const mine = spent.find((row) => row.sessionId === sessionId);
|
|
733
|
+
if (!mine || !(mine.share > 0)) return equal;
|
|
734
|
+
return Math.max(mine.share, equal);
|
|
648
735
|
}
|
|
649
736
|
|
|
650
737
|
// What a point of a window costs is a property of the plan, not of the moment,
|
|
@@ -768,6 +855,7 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
768
855
|
spentUSD: spent.cost,
|
|
769
856
|
spentTokens: spent.tokens,
|
|
770
857
|
turns: spent.turns,
|
|
858
|
+
subagentTurns: spent.subagentTurns,
|
|
771
859
|
recentTurns: recent.turns,
|
|
772
860
|
recentUSDPerHour: recent.cost / recentHours,
|
|
773
861
|
recentUSDPerTurn: recent.turns ? recent.cost / recent.turns : null,
|
|
@@ -789,6 +877,14 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
789
877
|
correctionUnreliable: false,
|
|
790
878
|
// The price-per-point this window derived from its own baseline.
|
|
791
879
|
calibration: null,
|
|
880
|
+
// True when the bucket quoted its limit in dollars, so the price of a
|
|
881
|
+
// point is known rather than learned.
|
|
882
|
+
metered: false,
|
|
883
|
+
// What the account's own list of limits says about this one.
|
|
884
|
+
severity: null,
|
|
885
|
+
isActive: false,
|
|
886
|
+
scoped: false,
|
|
887
|
+
family: null,
|
|
792
888
|
verdict: 'unknown',
|
|
793
889
|
};
|
|
794
890
|
|
|
@@ -840,16 +936,23 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
840
936
|
rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
|
|
841
937
|
? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
|
|
842
938
|
: null;
|
|
843
|
-
|
|
939
|
+
// A metered window has nothing to learn: its price per point is stated.
|
|
940
|
+
if (selfPriced && !extra.metered) window.calibration = selfPriced;
|
|
844
941
|
|
|
845
942
|
const known = extra.knownCalibration;
|
|
846
943
|
const usable =
|
|
847
944
|
known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
|
|
848
|
-
// Trust the better-sampled of the two, whichever that is
|
|
945
|
+
// Trust the better-sampled of the two, whichever that is; a stated price
|
|
946
|
+
// beats both.
|
|
947
|
+
const stated =
|
|
948
|
+
extra.metered && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
|
|
949
|
+
? { usdPerPercent: extra.usdPerPercent, turns: Infinity }
|
|
950
|
+
: null;
|
|
849
951
|
const chosen =
|
|
850
|
-
|
|
952
|
+
stated ||
|
|
953
|
+
(selfPriced && usable
|
|
851
954
|
? (usable.turns > selfPriced.turns ? usable : selfPriced)
|
|
852
|
-
: selfPriced || usable;
|
|
955
|
+
: selfPriced || usable);
|
|
853
956
|
|
|
854
957
|
if (after.cost > 0 && chosen) {
|
|
855
958
|
const pricePerPoint = chosen.usdPerPercent;
|
|
@@ -859,6 +962,10 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
859
962
|
// not the window. Better to leave the reading uncorrected and say the
|
|
860
963
|
// snapshot is old than to assert a budget that is gone.
|
|
861
964
|
if (rawPercent + sinceSnapshot > SATURATION_LIMIT) {
|
|
965
|
+
// Keep the size of the overshoot: the brief needs it to say how far
|
|
966
|
+
// past the snapshot the spending has gone, instead of repeating the
|
|
967
|
+
// snapshot figure for hours as if it were current.
|
|
968
|
+
window.pointsBeyondSnapshot = Math.round(sinceSnapshot);
|
|
862
969
|
sinceSnapshot = 0;
|
|
863
970
|
window.correctionUnreliable = true;
|
|
864
971
|
} else if (sinceSnapshot >= 1) {
|
|
@@ -884,14 +991,18 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
884
991
|
const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
|
|
885
992
|
const derived = measured ? spent.cost / percent : null;
|
|
886
993
|
const known = extra.knownCalibration;
|
|
887
|
-
const
|
|
888
|
-
|
|
994
|
+
const metered =
|
|
995
|
+
Boolean(extra.metered) && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0;
|
|
996
|
+
const priced = metered
|
|
997
|
+
? extra.usdPerPercent
|
|
998
|
+
: derived !== null
|
|
889
999
|
? derived
|
|
890
1000
|
: Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
|
|
891
1001
|
? extra.usdPerPercent
|
|
892
1002
|
: known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0
|
|
893
1003
|
? known.usdPerPercent
|
|
894
1004
|
: null;
|
|
1005
|
+
window.metered = metered;
|
|
895
1006
|
|
|
896
1007
|
if (percent !== null && priced !== null) {
|
|
897
1008
|
window.usdPerPercent = priced;
|
|
@@ -989,6 +1100,10 @@ function bindingWindow(windows) {
|
|
|
989
1100
|
const theirs = soonest(best);
|
|
990
1101
|
if (mine !== theirs) return mine < theirs ? w : best;
|
|
991
1102
|
|
|
1103
|
+
// Equally urgent by our own measure: the account says which limit it is
|
|
1104
|
+
// enforcing, and that is worth more than a rule of thumb.
|
|
1105
|
+
if (Boolean(w.isActive) !== Boolean(best.isActive)) return w.isActive ? w : best;
|
|
1106
|
+
|
|
992
1107
|
// Equally urgent: the shorter window is the one hit first in practice, so
|
|
993
1108
|
// the 5-hour limit wins a tie against the weekly one.
|
|
994
1109
|
const myspan = Number.isFinite(w.spanMs) ? w.spanMs : Infinity;
|
|
@@ -1023,6 +1138,14 @@ function formatUSD(value) {
|
|
|
1023
1138
|
return '$' + value.toFixed(3);
|
|
1024
1139
|
}
|
|
1025
1140
|
|
|
1141
|
+
// Money to two places. Three below a dollar is right for a per-turn price and
|
|
1142
|
+
// wrong for a total someone reads after every reply.
|
|
1143
|
+
function formatMoney(value) {
|
|
1144
|
+
if (value === null || !Number.isFinite(value)) return '-';
|
|
1145
|
+
if (value >= 100) return '$' + Math.round(value);
|
|
1146
|
+
return '$' + value.toFixed(2);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1026
1149
|
function formatTokens(value) {
|
|
1027
1150
|
if (!Number.isFinite(value)) return '-';
|
|
1028
1151
|
if (value >= 1e9) return (value / 1e9).toFixed(1) + 'B';
|
|
@@ -1099,6 +1222,49 @@ function otherLimits(utilization, threshold) {
|
|
|
1099
1222
|
return rows.sort((a, b) => b.percentUsed - a.percentUsed);
|
|
1100
1223
|
}
|
|
1101
1224
|
|
|
1225
|
+
// Beside the per-window buckets, the snapshot carries `limits`: one entry per
|
|
1226
|
+
// limit the account enforces, with a severity, whether it is the active one,
|
|
1227
|
+
// and for the per-model weeklies which model it scopes to. It is the account's
|
|
1228
|
+
// own description of its limits, and it can name a window the bucket table
|
|
1229
|
+
// does not: on a Max plan the Fable weekly sat at 17% while the shared weekly
|
|
1230
|
+
// read 11%, and nothing reported the higher of the two.
|
|
1231
|
+
const LIMIT_KINDS = { session: 'five_hour', weekly_all: 'seven_day' };
|
|
1232
|
+
|
|
1233
|
+
function limitWindows(utilization) {
|
|
1234
|
+
const list = utilization && Array.isArray(utilization.limits) ? utilization.limits : [];
|
|
1235
|
+
const rows = [];
|
|
1236
|
+
for (const limit of list) {
|
|
1237
|
+
if (!limit || typeof limit !== 'object' || typeof limit.percent !== 'number') continue;
|
|
1238
|
+
const resetsAt = limit.resets_at ? Date.parse(limit.resets_at) : null;
|
|
1239
|
+
const base = {
|
|
1240
|
+
kind: limit.kind,
|
|
1241
|
+
percent: limit.percent,
|
|
1242
|
+
severity: typeof limit.severity === 'string' ? limit.severity : null,
|
|
1243
|
+
isActive: Boolean(limit.is_active),
|
|
1244
|
+
resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
|
|
1245
|
+
family: null,
|
|
1246
|
+
};
|
|
1247
|
+
if (LIMIT_KINDS[limit.kind]) {
|
|
1248
|
+
rows.push(Object.assign(base, { key: LIMIT_KINDS[limit.kind] }));
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
if (limit.kind === 'weekly_scoped' && limit.scope && limit.scope.model) {
|
|
1252
|
+
const model = limit.scope.model;
|
|
1253
|
+
const name = model.display_name || model.id || 'model';
|
|
1254
|
+
const family = familyOf(name) || familyOf(model.id) || String(name).toLowerCase();
|
|
1255
|
+
rows.push(
|
|
1256
|
+
Object.assign(base, {
|
|
1257
|
+
key: 'seven_day_scoped:' + family,
|
|
1258
|
+
label: 'weekly (' + name + ')',
|
|
1259
|
+
family,
|
|
1260
|
+
spanMs: 7 * DAY,
|
|
1261
|
+
})
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return rows;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1102
1268
|
function collect(now) {
|
|
1103
1269
|
if (isCodex()) return codex.collect(now);
|
|
1104
1270
|
return collectClaude(now);
|
|
@@ -1236,11 +1402,10 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
|
|
|
1236
1402
|
// of its two windows in the payload, so it hands its own spans in rather than
|
|
1237
1403
|
// having them assumed.
|
|
1238
1404
|
const table = specs && specs.length ? specs : WINDOWS;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
// The per-model weekly windows only exist on some plans.
|
|
1242
|
-
if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
|
|
1405
|
+
const limits = limitWindows(utilization);
|
|
1406
|
+
const limitByKey = new Map(limits.map((limit) => [limit.key, limit]));
|
|
1243
1407
|
|
|
1408
|
+
const one = (spec, snapshot, own, limit) => {
|
|
1244
1409
|
const known = learned ? learned[spec.key] : null;
|
|
1245
1410
|
const refusal = refused.get(spec.key);
|
|
1246
1411
|
|
|
@@ -1261,34 +1426,91 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
|
|
|
1261
1426
|
}
|
|
1262
1427
|
}
|
|
1263
1428
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1429
|
+
// A bucket that quotes its limit in dollars needs no calibration: a
|
|
1430
|
+
// hundred dollars is a hundred points. Null on the plans seen so far, but
|
|
1431
|
+
// the field is there, and when it fills in it beats any estimate.
|
|
1432
|
+
const dollars =
|
|
1433
|
+
snapshot && Number.isFinite(snapshot.limit_dollars) && snapshot.limit_dollars > 0
|
|
1434
|
+
? snapshot.limit_dollars / 100
|
|
1435
|
+
: null;
|
|
1436
|
+
|
|
1437
|
+
const window = buildWindow(
|
|
1438
|
+
spec,
|
|
1439
|
+
anchored,
|
|
1440
|
+
own,
|
|
1441
|
+
now,
|
|
1442
|
+
Object.assign(
|
|
1443
|
+
{ fetchedAt, knownCalibration: known },
|
|
1444
|
+
windowStart === undefined ? {} : { windowStart },
|
|
1445
|
+
dollars === null ? {} : { metered: true, usdPerPercent: dollars }
|
|
1446
|
+
)
|
|
1447
|
+
);
|
|
1269
1448
|
if (refusal) {
|
|
1270
1449
|
window.refusedAt = refusal.at;
|
|
1271
1450
|
window.refusedResetsAt = refusal.resetsAt;
|
|
1272
1451
|
}
|
|
1452
|
+
|
|
1453
|
+
let result = window;
|
|
1273
1454
|
if (!window.stale) {
|
|
1274
|
-
|
|
1455
|
+
result = reconstructUnanchored(spec, window, own, now, known) || window;
|
|
1456
|
+
} else {
|
|
1457
|
+
// Rolled over. Rebuild from local history rather than going blind on it.
|
|
1458
|
+
const rebuilt = reconstructWindow(spec, snapshot, own, now);
|
|
1459
|
+
if (rebuilt) {
|
|
1460
|
+
result = buildWindow(
|
|
1461
|
+
spec,
|
|
1462
|
+
{ utilization: rebuilt.percentUsed, resets_at: null },
|
|
1463
|
+
own,
|
|
1464
|
+
now,
|
|
1465
|
+
{
|
|
1466
|
+
estimated: true,
|
|
1467
|
+
windowStart: rebuilt.windowStart,
|
|
1468
|
+
usdPerPercent: rebuilt.usdPerPercent,
|
|
1469
|
+
}
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
// What the account itself says about this limit rides along.
|
|
1474
|
+
if (limit) {
|
|
1475
|
+
result.severity = limit.severity;
|
|
1476
|
+
result.isActive = limit.isActive;
|
|
1275
1477
|
}
|
|
1478
|
+
return result;
|
|
1479
|
+
};
|
|
1276
1480
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
windowStart: rebuilt.windowStart,
|
|
1288
|
-
usdPerPercent: rebuilt.usdPerPercent,
|
|
1481
|
+
const windows = table
|
|
1482
|
+
.map((spec) => {
|
|
1483
|
+
let snapshot = utilization[spec.key];
|
|
1484
|
+
const limit = limitByKey.get(spec.key);
|
|
1485
|
+
// The account's own list can carry a window the bucket table does not.
|
|
1486
|
+
if ((!snapshot || typeof snapshot.utilization !== 'number') && limit) {
|
|
1487
|
+
snapshot = {
|
|
1488
|
+
utilization: limit.percent,
|
|
1489
|
+
resets_at: limit.resetsAt ? new Date(limit.resetsAt).toISOString() : null,
|
|
1490
|
+
};
|
|
1289
1491
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1492
|
+
// The per-model weekly windows only exist on some plans.
|
|
1493
|
+
if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
|
|
1494
|
+
return one(spec, snapshot, events, limit);
|
|
1495
|
+
})
|
|
1496
|
+
.filter(Boolean);
|
|
1497
|
+
|
|
1498
|
+
// A per-model weekly is a limit on one model's spend, so it is priced from
|
|
1499
|
+
// that model's calls alone; the shared windows still see everything.
|
|
1500
|
+
for (const limit of limits) {
|
|
1501
|
+
if (!limit.family) continue;
|
|
1502
|
+
const spec = { key: limit.key, label: limit.label, span: limit.spanMs };
|
|
1503
|
+
const own = events.filter((event) => familyOf(event.model) === limit.family);
|
|
1504
|
+
const snapshot = {
|
|
1505
|
+
utilization: limit.percent,
|
|
1506
|
+
resets_at: limit.resetsAt ? new Date(limit.resetsAt).toISOString() : null,
|
|
1507
|
+
};
|
|
1508
|
+
const window = one(spec, snapshot, own, limit);
|
|
1509
|
+
window.scoped = true;
|
|
1510
|
+
window.family = limit.family;
|
|
1511
|
+
windows.push(window);
|
|
1512
|
+
}
|
|
1513
|
+
return windows;
|
|
1292
1514
|
}
|
|
1293
1515
|
|
|
1294
1516
|
// What one session has spent, out of everything on record.
|
|
@@ -1296,12 +1518,14 @@ function sessionSpend(events, sessionId) {
|
|
|
1296
1518
|
if (!sessionId) return null;
|
|
1297
1519
|
let cost = 0;
|
|
1298
1520
|
let turns = 0;
|
|
1521
|
+
let tokens = 0;
|
|
1299
1522
|
for (const event of events) {
|
|
1300
1523
|
if (event.sessionId !== sessionId) continue;
|
|
1301
1524
|
cost += event.cost;
|
|
1302
|
-
|
|
1525
|
+
tokens += event.tokens || 0;
|
|
1526
|
+
if (!event.sidechain) turns += 1;
|
|
1303
1527
|
}
|
|
1304
|
-
return turns ? { turns, cost } : null;
|
|
1528
|
+
return turns ? { turns, cost, tokens } : null;
|
|
1305
1529
|
}
|
|
1306
1530
|
|
|
1307
1531
|
async function report(now, options) {
|
|
@@ -1413,7 +1637,8 @@ async function report(now, options) {
|
|
|
1413
1637
|
tokens: recent.tokens,
|
|
1414
1638
|
effort: dominantEffort(recentEvents),
|
|
1415
1639
|
},
|
|
1416
|
-
measuredTurns: events.length,
|
|
1640
|
+
measuredTurns: mainThread(events).length,
|
|
1641
|
+
subagentTurns: events.length - mainThread(events).length,
|
|
1417
1642
|
});
|
|
1418
1643
|
}
|
|
1419
1644
|
|
|
@@ -1578,7 +1803,14 @@ function render(data) {
|
|
|
1578
1803
|
(money ? padLeft('Left', 10) : '') + padLeft('Turns left', 12)
|
|
1579
1804
|
);
|
|
1580
1805
|
for (const window of data.windows) {
|
|
1581
|
-
const
|
|
1806
|
+
const bound = data.binding && window.key === data.binding.key;
|
|
1807
|
+
// The account's own severity, when it says critical, is worth a word.
|
|
1808
|
+
const critical = window.severity === 'critical';
|
|
1809
|
+
const marker = bound
|
|
1810
|
+
? ' <- binding' + (critical ? ', critical' : '')
|
|
1811
|
+
: critical
|
|
1812
|
+
? ' critical'
|
|
1813
|
+
: '';
|
|
1582
1814
|
lines.push(
|
|
1583
1815
|
' ' + pad(window.label, 15) +
|
|
1584
1816
|
padLeft(
|
|
@@ -1693,7 +1925,10 @@ function render(data) {
|
|
|
1693
1925
|
} else {
|
|
1694
1926
|
lines.push(' Recent pace no turns in the last hour');
|
|
1695
1927
|
}
|
|
1696
|
-
lines.push(
|
|
1928
|
+
lines.push(
|
|
1929
|
+
' Measured ' + formatCount(data.measuredTurns) + ' turns of local transcript' +
|
|
1930
|
+
(data.subagentTurns > 0 ? ' (+' + formatCount(data.subagentTurns) + ' subagent calls)' : '')
|
|
1931
|
+
);
|
|
1697
1932
|
|
|
1698
1933
|
if (data.windows.some((window) => window.adjusted)) {
|
|
1699
1934
|
lines.push(
|
|
@@ -1861,6 +2096,102 @@ function renderForecast(data, turns) {
|
|
|
1861
2096
|
return lines.join('\n');
|
|
1862
2097
|
}
|
|
1863
2098
|
|
|
2099
|
+
// The session history kept by the Stop hook, one row per session.
|
|
2100
|
+
function sessionTokens(session) {
|
|
2101
|
+
const t = (session && session.tokens) || {};
|
|
2102
|
+
return (t.input || 0) + (t.cacheWrite || 0) + (t.cacheRead || 0) + (t.output || 0);
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function shortId(sessionId) {
|
|
2106
|
+
return String(sessionId || '').slice(0, 8);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function renderSessions(list, now) {
|
|
2110
|
+
const lines = [];
|
|
2111
|
+
lines.push('Sessions on this machine, newest first');
|
|
2112
|
+
lines.push('');
|
|
2113
|
+
if (!list || !list.length) {
|
|
2114
|
+
lines.push(' No sessions on record yet. The Stop hook writes one after each reply, so');
|
|
2115
|
+
lines.push(' this fills in as soon as a session with the hook installed has run.');
|
|
2116
|
+
return lines.join('\n');
|
|
2117
|
+
}
|
|
2118
|
+
lines.push(
|
|
2119
|
+
' ' + pad('Id', 10) + pad('When', 12) + pad('Project', 24) + padLeft('Prompts', 7) +
|
|
2120
|
+
padLeft('Turns', 9) + padLeft('Tokens', 9) + padLeft('Cost', 9)
|
|
2121
|
+
);
|
|
2122
|
+
for (const session of list) {
|
|
2123
|
+
const turns =
|
|
2124
|
+
String(session.turns || 0) + (session.subagentTurns > 0 ? '+' + session.subagentTurns : '');
|
|
2125
|
+
lines.push(
|
|
2126
|
+
' ' + pad(shortId(session.sessionId), 10) +
|
|
2127
|
+
pad(Number.isFinite(session.lastAt) ? formatDuration(now - session.lastAt) + ' ago' : '-', 12) +
|
|
2128
|
+
pad(shortenProject(session.project || '-', 22), 24) +
|
|
2129
|
+
padLeft(session.prompts || 0, 7) +
|
|
2130
|
+
padLeft(turns, 9) +
|
|
2131
|
+
padLeft(formatTokens(sessionTokens(session)), 9) +
|
|
2132
|
+
padLeft(formatMoney(session.cost || 0), 9) +
|
|
2133
|
+
(Number.isFinite(session.endedAt) ? '' : ' open')
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
lines.push('');
|
|
2137
|
+
lines.push(' Turns are main-thread calls; +N is what subagents made on top.');
|
|
2138
|
+
return lines.join('\n');
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function renderSession(session, now) {
|
|
2142
|
+
const lines = [];
|
|
2143
|
+
lines.push('Session ' + shortId(session.sessionId) + (session.project ? ' (' + session.project + ')' : ''));
|
|
2144
|
+
lines.push('');
|
|
2145
|
+
const started = Number.isFinite(session.firstAt)
|
|
2146
|
+
? formatClock(session.firstAt) + ', ' + formatDuration(now - session.firstAt) + ' ago, '
|
|
2147
|
+
: '';
|
|
2148
|
+
const ended = Number.isFinite(session.endedAt)
|
|
2149
|
+
? 'closed ' + formatDuration(now - session.endedAt) + ' ago' + (session.reason ? ' (' + session.reason + ')' : '')
|
|
2150
|
+
: 'still open';
|
|
2151
|
+
lines.push(' Started ' + started + ended);
|
|
2152
|
+
lines.push(' Prompts ' + (session.prompts || 0));
|
|
2153
|
+
lines.push(
|
|
2154
|
+
' Turns ' + (session.turns || 0) +
|
|
2155
|
+
(session.subagentTurns > 0 ? ', plus ' + session.subagentTurns + ' by subagents' : '')
|
|
2156
|
+
);
|
|
2157
|
+
const t = session.tokens || {};
|
|
2158
|
+
lines.push(
|
|
2159
|
+
' Tokens ' + formatTokens(sessionTokens(session)) + ': input ' + formatTokens(t.input || 0) +
|
|
2160
|
+
', cache write ' + formatTokens(t.cacheWrite || 0) + ', cache read ' + formatTokens(t.cacheRead || 0) +
|
|
2161
|
+
', output ' + formatTokens(t.output || 0) +
|
|
2162
|
+
(t.reasoning > 0 ? ' (' + formatTokens(t.reasoning) + ' of it reasoning)' : '')
|
|
2163
|
+
);
|
|
2164
|
+
lines.push(' Cost ' + formatMoney(session.cost || 0));
|
|
2165
|
+
if (Number.isFinite(session.context) && session.context > 0) {
|
|
2166
|
+
lines.push(' Context ' + formatTokens(session.context) + ' tokens at the last call');
|
|
2167
|
+
}
|
|
2168
|
+
const models = Object.keys(session.models || {}).sort(
|
|
2169
|
+
(a, b) => (session.models[b].cost || 0) - (session.models[a].cost || 0)
|
|
2170
|
+
);
|
|
2171
|
+
models.forEach((id, index) => {
|
|
2172
|
+
const row = session.models[id];
|
|
2173
|
+
lines.push(
|
|
2174
|
+
(index === 0 ? ' Models ' : ' ') + pad(id, 24) +
|
|
2175
|
+
padLeft((row.turns || 0) + ' turns', 12) + padLeft(formatMoney(row.cost || 0), 10)
|
|
2176
|
+
);
|
|
2177
|
+
});
|
|
2178
|
+
return lines.join('\n');
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// "last", an exact id, or an unambiguous prefix of one.
|
|
2182
|
+
function pickSession(list, which) {
|
|
2183
|
+
const rows = list || [];
|
|
2184
|
+
if (!rows.length) return null;
|
|
2185
|
+
const want = String(which || 'last');
|
|
2186
|
+
if (want === 'last') {
|
|
2187
|
+
return rows.slice().sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0))[0];
|
|
2188
|
+
}
|
|
2189
|
+
const exact = rows.find((row) => row.sessionId === want);
|
|
2190
|
+
if (exact) return exact;
|
|
2191
|
+
const prefixed = rows.filter((row) => String(row.sessionId || '').startsWith(want));
|
|
2192
|
+
return prefixed.length === 1 ? prefixed[0] : null;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
1864
2195
|
async function main(argv) {
|
|
1865
2196
|
// Settle the host before anything reads a file, so one run never mixes one
|
|
1866
2197
|
// agent's percentages with the other's turns.
|
|
@@ -1872,6 +2203,29 @@ async function main(argv) {
|
|
|
1872
2203
|
return 0;
|
|
1873
2204
|
}
|
|
1874
2205
|
|
|
2206
|
+
// The session history is a file the Stop hook keeps, so neither of these
|
|
2207
|
+
// opens a transcript. Required lazily: tally.js depends on this module.
|
|
2208
|
+
const sessionAt = argv.indexOf('--session');
|
|
2209
|
+
if (sessionAt !== -1 || argv.indexOf('--sessions') !== -1) {
|
|
2210
|
+
const tally = require('./tally.js');
|
|
2211
|
+
const list = tally.sessions(tally.readState());
|
|
2212
|
+
const now = Date.now();
|
|
2213
|
+
const json = argv.indexOf('--json') !== -1;
|
|
2214
|
+
if (sessionAt !== -1) {
|
|
2215
|
+
const next = argv[sessionAt + 1];
|
|
2216
|
+
const which = next && next.indexOf('--') !== 0 ? next : 'last';
|
|
2217
|
+
const picked = pickSession(list, which);
|
|
2218
|
+
if (!picked) {
|
|
2219
|
+
process.stderr.write('usage: no session matches "' + which + '". Run --sessions to list them.\n');
|
|
2220
|
+
return 2;
|
|
2221
|
+
}
|
|
2222
|
+
process.stdout.write((json ? JSON.stringify(picked, null, 2) : renderSession(picked, now)) + '\n');
|
|
2223
|
+
return 0;
|
|
2224
|
+
}
|
|
2225
|
+
process.stdout.write((json ? JSON.stringify(list, null, 2) : renderSessions(list, now)) + '\n');
|
|
2226
|
+
return 0;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
1875
2229
|
// Codex writes its meter into the session rollouts, so the cached reading is
|
|
1876
2230
|
// only as fresh as the last request it made. Asking Codex itself is a second
|
|
1877
2231
|
// and a child process, which is why it is opt-in rather than the default.
|
|
@@ -1915,13 +2269,8 @@ async function main(argv) {
|
|
|
1915
2269
|
return 0;
|
|
1916
2270
|
}
|
|
1917
2271
|
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
process.stderr.write('usage: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
1921
|
-
process.exitCode = 1;
|
|
1922
|
-
});
|
|
1923
|
-
}
|
|
1924
|
-
|
|
2272
|
+
// Assigned before main() can run: tally.js requires this module back, and a
|
|
2273
|
+
// lazy require from inside main() would otherwise see an empty exports object.
|
|
1925
2274
|
module.exports = {
|
|
1926
2275
|
main,
|
|
1927
2276
|
setHost,
|
|
@@ -1939,12 +2288,15 @@ module.exports = {
|
|
|
1939
2288
|
costOf,
|
|
1940
2289
|
tokensOf,
|
|
1941
2290
|
eventFrom,
|
|
2291
|
+
promptFrom,
|
|
1942
2292
|
readEvents,
|
|
2293
|
+
readCalibration,
|
|
1943
2294
|
buildWindow,
|
|
1944
2295
|
reconstructWindow,
|
|
1945
2296
|
SATURATION_LIMIT,
|
|
1946
2297
|
MIN_BASELINE_TURNS,
|
|
1947
2298
|
buildWindows,
|
|
2299
|
+
limitWindows,
|
|
1948
2300
|
lastRejections,
|
|
1949
2301
|
bindingWindow,
|
|
1950
2302
|
criticalOthers,
|
|
@@ -1962,7 +2314,11 @@ module.exports = {
|
|
|
1962
2314
|
MIN_PACE_SAMPLE,
|
|
1963
2315
|
formatDuration,
|
|
1964
2316
|
formatUSD,
|
|
2317
|
+
formatMoney,
|
|
1965
2318
|
formatCount,
|
|
2319
|
+
renderSessions,
|
|
2320
|
+
renderSession,
|
|
2321
|
+
pickSession,
|
|
1966
2322
|
verdictLine,
|
|
1967
2323
|
render,
|
|
1968
2324
|
report,
|
|
@@ -1983,3 +2339,10 @@ module.exports = {
|
|
|
1983
2339
|
formatTokens,
|
|
1984
2340
|
PLANS,
|
|
1985
2341
|
};
|
|
2342
|
+
|
|
2343
|
+
if (require.main === module) {
|
|
2344
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
2345
|
+
process.stderr.write('usage: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
2346
|
+
process.exitCode = 1;
|
|
2347
|
+
});
|
|
2348
|
+
}
|