Forecasts whether you will hit your 5-hour or weekly usage limit before it resets, learns your usage pattern over time and suggests what to change.

A Claude Code mod that forecasts your 5‑hour and weekly usage limits, learns your usage pattern and suggests what to change before you run out.
5h ███▓▓▒··│·· 42% ↻ 2h30 → 68% (61–77) risk 0% ● OK │ wk █████▓▓▓│▒▒ 61% ↻ 3d → 104% (88–119) risk 75% ● SLOW DOWN · 14:02
| Field | Meaning |
|---|---|
· 14:02 | When Claude Code last reported your limits. Claude Code gets them with each response, so they update when you send a message; usage in other sessions or on claude.ai shows up after your next one. |
5h / wk | The 5-hour window and the weekly window. |
███▓▓▒··│·· | A small version of the pane's bar, 0–125% in 10 cells. Left out when the terminal is too narrow for the line. |
42% | Used now, exactly as Claude Code reports it. |
↻ 2h30 | Time until the window resets (1d 23h beyond a day). |
→ 68% | Point forecast: the expected percent at reset. |
(61–77) | 80% prediction interval: the final value lands in this range about 8 times out of 10. |
risk 0% | Probability of reaching 100% before the reset. |
OK / SLOW DOWN / HOLD ON | The verdict (rules below). |
The fields are the same whatever the verdict. A – means not known yet, for example before the mod has learned enough. Colors:
When a window is under pressure, the warning headline, the top suggestions and two buttons appear under the line: Hide keeps the warning away until the situation changes (the line stays); Details opens the pane.
/limits-forecast pane5-hour ████████▓▓▓▓▓▓▒·····│·····
used 42% now
resets in 2h30 · 16:32
at reset ~68% · 80% range 61–77% (from 12 past days)
risk 0% chance to run out before the reset
speed 9.1%/h over your last hour
even pace 50% by now · you are under by 8
█ is used, ▓ runs up to the forecast at the reset, ▒ from there to the top of the 80% range, and │ marks the limit at 100%. It's a fan chart in one row: it draws the side of the range that decides whether you hit the limit, and the numbers next to it give the whole range.The VS Code extension draws no plugin panes or bands, so there /limits-forecast prints the same report as text. It goes by whether anything of the mod has been drawn yet. /limits-forecast text asks for the text version anywhere.
/limits-forecast exportThis writes 13 CSV tables and a summary.json for retrospectives. The files are listed under Your data.
You get a toast when the verdict gets worse, and when a window crosses 80% and 90%.
At the prompt of a Claude Code session:
/plugin install limits-forecast --marketplace danylo-konovalenko-brainsport/limits-forecast
Or from a local clone:
/plugin install limits-forecast --marketplace C:\path\to\limits-forecast
Answer y to add the marketplace, then choose user scope so it runs in every session.
After changing the code of a local install, run /reload-plugins. There's nothing to rebuild and no version to bump, because a local marketplace is read straight from the folder.
To try it without installing:
claude --plugin-dir ./plugins/limits-forecast
Requirements: a Claude Code build with mod support (developed on 2.1.292) and a subscription plan. Only subscription plans report rate-limit windows.
| Question | Answer |
|---|---|
| Does it call Claude or any other model? | No. The code never touches $.model. |
| Does it send data anywhere? | No. There are no HTTP calls. It reads local files and writes to ~/.claude/limit-metrics/. |
| Does it use your limit? | No. It costs nothing from the limits it watches. |
| How does it learn? | By fitting a few numbers to your history with the formulas under The math. |
flowchart LR
A[Claude Code<br/>response] -->|percent used<br/>+ reset time| R[Readings log]
T[Transcripts<br/>~/.claude/projects] -->|tokens per message| U[Usage per 15 min<br/>in $-units]
T -->|refused requests| H[Past limit hits]
H -->|0% at start,<br/>100% at hit| R
R --> C[Calibration<br/>% per unit]
U --> C
U --> P[Weekly profile<br/>weekday × hour]
C --> F[Forecast<br/>+ 80% interval + risk]
P --> F
U --> F
F --> V[Verdict · limits line · pane · tips]
F -->|logged hourly| S[Self-scoring<br/>after reset]
R --> S
five_hour, seven_day) with the percent used and the reset time. The mod logs each move of a window.~/.claude/projects and sums the tokens per 15-minute bucket, converted to units. Unchanged files come from a cache. Files over the runtime's 4 MiB read limit are streamed through cat (type on Windows), keeping only the lines the parser needs.error: "rate_limit" and quotaLimits { rateLimitType, resetsAt }. Each hit becomes two exact readings: 0% when the window started and 100% at the hit. So if you've ever hit a limit, calibration works from day one.Different tokens cost the limit different amounts. Tokens are weighted by the ratios of Anthropic's published API list prices (pricing):
u \;=\; f_{\text{model}}\cdot\frac{\text{in} + 1.25\,\text{cache\_write} + 0.1\,\text{cache\_read} + 5\,\text{out}}{10^6},
\qquad f_{\text{Opus}}=5,\; f_{\text{Sonnet}}=3,\; f_{\text{Haiku}}=1
One unit is about one US dollar at API list prices. It isn't what your subscription costs.
Each message is counted once per message id, because Claude Code writes every content block of a message as its own line with the same usage. Usage is summed into 15-minute buckets with a prefix-sum index, so the usage between any two moments is an O(log n) lookup. The two edge buckets are prorated. (units, usageIndex in model.ts)
Anthropic doesn't publish how usage maps to percent, so the mod estimates it. Take every stretch between two readings of the same window. A stretch must last at least 1 h for the 5-hour window and 12 h for the weekly one. For stretch $i$:
w_i = 2^{-\text{age}_i / 14\,\text{d}}$: exponential recency weighting, half-life 14 days, looking back 28 daysThe model is $y_i = k\,x_i + \varepsilon_i$ with $\mathrm{Var}(\varepsilon_i) \propto x_i$, since larger stretches are noisier in absolute terms. Weighted least squares through the origin under that model is the ratio estimator (Cochran, Sampling Techniques, ch. 6):
\hat k \;=\; \frac{\sum_i w_i\,y_i}{\sum_i w_i\,x_i}
Its standard error comes from the residuals, with a small-sample correction $n/(n-1)$:
s^2 = \frac{n}{n-1}\cdot\frac{\sum_i w_i\,(y_i-\hat k x_i)^2 / x_i}{\sum_i w_i},
\qquad
\mathrm{SE}(\hat k) = \sqrt{\frac{s^2 \sum_i w_i^2\,x_i}{\left(\sum_i w_i x_i\right)^2}}
Rules:
Change detection. A new plan, or Anthropic changing the limits, makes the old stretches wrong. Waiting for them to fade out of the 28 days would take weeks. So when the two latest stretches both miss the fit on the same side, each by more than
\max\!\Big(30\%,\ 3\,\tfrac{\mathrm{SE}(\hat k)}{\hat k},\ \tfrac{2}{y_i}\Big)
the mod learns from those two alone. The last term allows for small stretches being coarse, since windows move in whole points. A single odd stretch is treated as noise. The pane says when it re-learned.
(calibrate, ratioFit)
Is Opus really 5/3 as expensive as Sonnet against the limit? Using the same stretches, the mod fits Opus and non-Opus usage separately, again through the origin with weights $w_i / (x_{1i}+x_{2i})$:
y_i = a\,x_{\text{opus},i} + b\,x_{\text{other},i} + \varepsilon_i
and reports $a/b$:
The standard error of the ratio comes from the delta method:
\mathrm{Var}\!\left(\tfrac{a}{b}\right) \approx \frac{\mathrm{Var}(a)}{b^2} + \frac{a^2\mathrm{Var}(b)}{b^4} - \frac{2a\mathrm{Cov}(a,b)}{b^3}
It needs ≥ 6 stretches, and both kinds of usage must vary independently, which the determinant check enforces. Otherwise the split can't be identified and the check stays silent.
Once the ratio is clearly different from 1, Opus usage is counted with it everywhere: the conversion, the profile and the forecast. "Clearly" means more than 2 standard errors away from 1, and measured to within 25%. (weightCheck, ratioOfTwo, opusWeight, reweightOpus)
A raw 168-cell hour-of-week table would be mostly empty. Instead the mod uses a factorized (multiplicative) seasonal model with 7 + 24 parameters:
\text{expected}(d, h) \;=\; \bar U_d \cdot s_h
(profile, expectedUnits)
Let $H$ be the hours to reset, $p$ the percent used now, and $L$ the look-back: 1 h for the 5-hour window, 24 h for the week.
U_{\text{usual}} = \int_{\text{now}}^{\text{reset}} \text{expected}(t)\,dt$A burst doesn't last until the reset, and a slow morning doesn't either. So the deviation decays exponentially (mean reversion) with time constant $\tau$: 1 h for the 5-hour window, 12 h for the week. Integrated over the time left:
B = \max\!\Big(0,\; U_{\text{usual}} + \Delta\,\tau\,\big(1-e^{-H/\tau}\big)\Big),
\qquad
\hat P = p + \hat k\,B
The model forecast needs $\hat k$, at least 3 days of history and a known reset time. Until then the forecast is the baseline below. (forecast)
\text{rate} = \frac{\hat k\,U_{\text{last }L}}{L},
\qquad
P_{\text{baseline}} = p + \text{rate}\cdot H
Before $\hat k$ is known, the rate is the ordinary least-squares slope of the readings themselves. That needs ≥ 3 readings spanning ≥ $L/4$. The baseline does two jobs:
(readingRate, slope)
How much could the rest of this window differ from the forecast? The mod looks at how the same stretch went in the past:
From the past usages $u_j$ with mean $\bar u$, build one scenario per past stretch, with the same forecast and that stretch's deviation from normal:
S_j = p + \hat k\cdot\max\!\big(0,\; B + u_j - \bar u\big)
The scenarios are smoothed into one distribution, a Gaussian kernel density estimate. Each scenario gets a kernel whose width $\sigma$ combines Silverman's rule-of-thumb bandwidth $h$ with the uncertainty of $\hat k$ (the two error sources treated as independent):
F(x) = \frac1n\sum_j \Phi\!\left(\frac{x - S_j}{\sigma}\right),
\qquad
\sigma = \sqrt{h^2 + \big(\mathrm{SE}(\hat k)\,B\big)^2},
\qquad
h = 0.9\,\min\!\big(s,\ \tfrac{\text{IQR}}{1.34}\big)\,n^{-1/5}
$\Phi$ is the standard normal CDF. The 80% prediction interval and the risk are both read from $F$, so they always agree:
\text{lo} = \max\big(p,\ F^{-1}(0.1)\big),
\qquad
\text{hi} = F^{-1}(0.9),
\qquad
\text{risk} = 1 - F(100)
$F^{-1}$ is found by bisection. The interval is clipped below at $p$ because usage never goes back. Smoothing matters with few past stretches: the 10% and 90% sample quantiles of 4 weeks always lie inside the observed weeks, which makes a raw interval too narrow, and a count-based risk could only move in steps of 1/n. If the scenarios don't vary at all, the plain sample quantiles (type 7, Hyndman & Fan 1996) and the plain share at or above 100% are used.
The interval and risk need ≥ 3 past stretches, and the pane says how many it compared with. Regular habits and more history give a narrower range; irregular use gives a wider one.
The coefficient of variation of your complete past weeks (up to 8):
\text{CV} = \frac{s}{\bar x}
"Your weeks vary ±48%" means CV = 0.48. (regularity, cv)
Several settings above are reasonable guesses: how fast a deviation fades ($\tau$), the profile's half-life, the width of the range. Once an hour the mod checks them against your own history with a rolling-origin backtest (time-series cross-validation; Hyndman & Athanasopoulos, §5.10):
\tau \in \{15\text{ m}, 1, 2, 4\text{ h}\}$ (5-hour) or $\{3, 12, 24, 48\text{ h}\}$ (week), and profile half-lives of $\{7, 14, 28\}$ days. It keeps the pair with the smallest mean absolute error, but only if it beats the defaults by at least 5%. A smaller gain over a few weeks is easily noise.c \in \{0.5, \dots, 4\}$ until the replayed 80% ranges held closest to 80% of the outcomes. This is calibrating the prediction interval on held-out data, the idea behind conformal prediction. Ties go to the factor nearest 1. Scaling the deviations also scales the kernel bandwidth, so the range and the risk stay one distribution.Replayed moments a few hours apart share most of their future, so they are not independent. The mod counts them as $\min\big(n,\ \lfloor \text{span} / \text{horizon} \rfloor + 1\big)$ and uses tuned settings only from 10 independent cases. Before that it uses the defaults:
The pane's "fit" rows show what was learned, for example "range ×2.5 so it held 80% instead of 59%". (tune, expectedBase, scenarios)
| Verdict | When |
|---|---|
| HOLD ON | ≥ 95% used, or the current speed reaches 100% before the reset and within 45 min (5-hour) / 1 day (week). |
| SLOW DOWN | risk > 50% (or, before risk exists, point forecast > 100%), or ≥ 85% used. |
| OK | Otherwise. |
Only hard data (the limit itself, or the measured speed) can say HOLD ON. A forecast from your usual pattern can only say SLOW DOWN.
Shown when a window is tight: not OK, or a forecast ≥ 100%, or a risk ≥ 50%. Each one is triggered by a measurement:
| Tip | Trigger |
|---|---|
/compact or /clear | main context > 120k tokens (every message re-sends it) |
| Switch to Sonnet | Opus > 60% of the last 5 h of usage (Sonnet makes the same limit last ≈ 5/3 ≈ 1.7× longer) |
| Fewer subagents | subagents > 40% of the last 5 h |
| Take a break | only the 5-hour window is in trouble: a break until its reset costs nothing from the week |
| Daily budget | the weekly window is in trouble: $(100 - p) / \text{days left}$ per day |
| Room for big tasks | nothing tight, > 10 points under an even pace this week, forecast < 90% and risk < 25% |
The export records whether each tip was followed within the hour, for example a model switch or the context shrinking after /compact.
Once an hour, each window's forecast is logged. One forecast per window per hour counts, even across sessions. After the window resets, each forecast is compared with the final percent $y$, over the last 28 days.
| Metric | Formula | Good means |
|---|---|---|
| MAE: mean absolute error | $\frac1n\sum \lvert \hat P - y\rvert$ | small |
| Bias: mean error | $\frac1n\sum (\hat P - y)$ | ≈ 0 (positive means the forecasts ran high) |
| Coverage of the 80% interval | share with $\text{lo} - 0.5 \le y \le \text{hi} + 0.5$ | ≈ 80%: far less means overconfident, far more means too cautious |
| Sharpness: mean interval width | $\frac1n\sum(\text{hi}-\text{lo})$ | as narrow as coverage allows |
| Brier score of the risk (Brier, 1950) | $\frac1n\sum(\text{risk} - \mathbb 1[y \ge 100])^2$ | 0 is perfect, 0.25 is a coin toss |
| Skill vs. the baseline | $1 - \dfrac{\text{MAE}_{\text{model}}}{\text{MAE}_{\text{baseline}}}$ | > 0 beats "the current speed holds" |
Notes:
The pane shows these in plain words, and forecasts.csv has every scored forecast for your own analysis.
Everything stays in ~/.claude/limit-metrics/ (or $CLAUDE_CONFIG_DIR/limit-metrics/).
| File | Contents |
|---|---|
log-YYYY-MM-<session>.jsonl | Readings, turns (tokens, context size, duration, interrupted or not), forecasts and events (warnings shown or hidden, tips, pane opened). One file per session and month. Written every 5 minutes and at session end. |
rollup-YYYY-MM.json | Each transcript's usage per day: tokens per model, tool calls, active quarter-hours, project, effort level, attribution (skill, plugin, MCP server, subagent type), turn and thinking time, interrupted answers, compactions and limit hits. Claude Code deletes transcripts after cleanupPeriodDays (30 by default); the rollups keep your history. |
history-cache.json | Usage per 15 minutes and limit hits per transcript, keyed by size and modification time. Kept for 10 weeks, also after Claude Code deletes the transcript, so the forecast can compare with up to 9 past weeks. |
/limits-forecast export → export/)| File | Contents |
|---|---|
usage-daily.csv | date, project, model, tokens by type, API-equivalent $ |
tools-daily.csv | tool calls per day and project |
active-daily.csv | active hours, turn hours, thinking hours, interrupted answers |
effort-daily.csv | usage per effort level |
attribution-daily.csv | usage per skill, plugin, MCP server and subagent type |
compactions.csv | manual or auto, tokens before and after |
limits.csv | every reading |
limit-hits.csv | every limit hit, including from before the mod was installed, with hours blocked and retries |
forecasts.csv | every forecast with its outcome |
turns.csv | every turn logged live |
events.csv | warnings, hides, pane opens |
tips.csv | each tip shown and whether it was followed |
summary.json | totals, per project, per effort, per attribution, limit hits, compactions, time, forecast quality, warnings |
Privacy: only numbers and model, tool and project names are stored. Prompts, answers and code are never stored.
cat/type. The pane shows how many couldn't be read.claude plugin validate plugins/limits-forecast
claude plugin test plugins/limits-forecast
plugins/limits-forecast/
├── hooks/
│ ├── stats.ts # mean, type-7 quantile, CV, OLS slope, ratio estimator, 2-variable WLS
│ ├── model.ts # units, buckets, transcript parser, calibration, profile, forecast, scoring, limits line text
│ ├── retro.ts # logs, daily rollups, limit hits, CSV/JSON export
│ └── register.tsx # wiring: Claude Code events, limits line and warning above the prompt, pane, /limits-forecast command
├── tests/ # model, rhooks/register.tsx 847 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { View } from '../types'
5import {
6 addToBuckets,
7 busiestDays,
8 calibrate,
9 DAY,
10 dedupeReadings,
11 dur,
12 evaluate,
13 fmtPct,
14 fmtRange,
15 forecast,
16 HOUR,
17 isWorse,
18 LABEL,
19 mergeBuckets,
20 hitReadings,
21 mergeHits,
22 normReset,
23 parseTranscript,
24 TRANSCRIPT_LINE,
25 pastWeeks,
26 rangeBar,
27 profile,
28 regularity,
29 SHORT,
30 hhmm,
31 statusParts,
32 statusText,
33 suggestions,
34 toLog,
35 usageIndex,
36 VERDICT_TEXT,
37 weightCheck,
38 opusWeight,
39 reweightOpus,
40 tune,
41 MIN_REPLAY,
42 worst,
43} from './model'
44import type { BarPart, Buckets, Calib, Forecast, ForecastLog, Hit, Reading, Tuning, Turn, Verdict } from './model'
45import { blockedMs, buildExport, byMonth, emptyLog, forecastEntry, limitHits, logName, parseLog, projectOf, rollupTranscript } from './retro'
46import type { DayRow, Log, Rollup } from './retro'
47
48const PANE = 'limits'
49const view = atom({ plugin: 'limits-forecast', key: 'view' } as const, null)
50const hiddenKey = atom({ plugin: 'limits-forecast', key: 'hiddenKey' } as const, '')
51
52const MAX_READ = 4 * 1024 * 1024
53const FLUSH_MS = 5 * 60_000
54/** Whether any surface has drawn the band or the pane; VS Code draws neither, though the engine places the pane. */
55let drawsUi = false
56const KEEP_MS = 10 * 7 * DAY
57/** Readings and forecasts of all sessions are loaded this far back. */
58const LOG_DAYS = 40
59const KINDS = ['five_hour', 'seven_day']
60
61type FileCache = { size: number; mtimeMs: number; buckets: Buckets; hits: Hit[] }
62type HistoryCache = { version: 3; files: Record<string, FileCache> }
63type LogFile = { size: number; mtimeMs: number; log: Log }
64
65// This process's own data; values the UI reads live in atoms.
66let base = ''
67let folder = ''
68let sessionKey = ''
69let own: Log = emptyLog()
70const others = new Map<string, LogFile>()
71let liveTurns: Turn[] = []
72let scanBuckets: Buckets = {}
73let scanAt = 0
74let scanning = false
75let scanInfo = { files: 0, skipped: 0 }
76let scanHits: Hit[] = []
77let context: number | undefined
78/** When Claude Code last reported the limits: they come with responses only. */
79let reportedAt: number | undefined
80const lastLive: Record<string, Reading> = {}
81const logLines: Record<string, string[]> = {}
82const dirty = new Set<string>()
83const notified = new Set<string>()
84const forecastLogged = new Set<string>()
85let prevOverall: Verdict = 'ok'
86let warned = ''
87let tipsLogged = new Set<string>()
88let timer: { cancel: () => void } | undefined
89
90const monthOf = (t: number) => new Date(t).toISOString().slice(0, 7)
91const logPath = (month: string) => `${folder}/log-${month}-${sessionKey}.jsonl`
92const recentMonths = (now: number) => new Set([0, 10, 20, 30, LOG_DAYS].map(d => monthOf(now - d * DAY)))
93
94function log(t: number, entry: Record<string, unknown>) {
95 const month = monthOf(t)
96 ;(logLines[month] ??= []).push(JSON.stringify({ t, ...entry }))
97 dirty.add(month)
98}
99
100async function flush($: EngineInterface) {
101 for (const month of [...dirty]) {
102 dirty.delete(month)
103 await $.fs.write(logPath(month), (logLines[month] ?? []).join('\n') + '\n').catch(() => dirty.add(month))
104 }
105}
106
107async function init($: EngineInterface) {
108 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
109 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
110 base = (configured ?? `${home}/.claude`).replace(/\\/g, '/').replace(/\/$/, '')
111 folder = `${base}/limit-metrics`
112 sessionKey = (await $.session.id()).slice(0, 8)
113
114 // This session's own lines go back into the buffer, so a reload loses nothing.
115 const months = recentMonths(await $.clock.now())
116 own = emptyLog()
117 for (const month of months) {
118 const text = await $.fs.read(logPath(month)).catch(() => '')
119 const lines = text.split('\n').filter(Boolean)
120 if (lines.length) logLines[month] = lines
121 parseLog(text, sessionKey, own)
122 }
123}
124
125/** Other sessions' logs: their readings and forecasts, re-read when a file changed. */
126async function loadLogs($: EngineInterface, now: number) {
127 const months = recentMonths(now)
128 for (const entry of await $.fs.list(folder).catch(() => [])) {
129 const name = logName(entry.name)
130 if (!name || name.session === sessionKey || !months.has(name.month) || entry.size > MAX_READ) continue
131 const path = `${folder}/${entry.name}`
132 const old = others.get(path)
133 if (old && old.size === entry.size && old.mtimeMs === entry.mtimeMs) continue
134 const text = await $.fs.read(path).catch(() => '')
135 others.set(path, { size: entry.size, mtimeMs: entry.mtimeMs, log: parseLog(text, name.session) })
136 }
137}
138
139function allLogs(now: number): { readings: Reading[]; forecasts: ForecastLog[] } {
140 const from = now - LOG_DAYS * DAY
141 const logs = [own, ...[...others.values()].map(f => f.log)]
142 return {
143 readings: dedupeReadings(logs.flatMap(l => l.readings).filter(r => r.t >= from)),
144 forecasts: logs.flatMap(l => l.forecasts).filter(f => f.t >= from),
145 }
146}
147
148/** Keeps each freshly read transcript's days in the monthly rollups, which outlive the transcripts. */
149async function writeRollups($: EngineInterface, fresh: Record<string, { project: string; days: Record<string, DayRow> }>) {
150 const months: Record<string, Rollup['files']> = {}
151 for (const [path, f] of Object.entries(fresh)) {
152 for (const [month, days] of Object.entries(byMonth(f.days))) (months[month] ??= {})[path] = { project: f.project, days }
153 }
154 for (const [month, files] of Object.entries(months)) {
155 const path = `${folder}/rollup-${month}.json`
156 let cur: Rollup = { version: 1, files: {} }
157 if (await $.fs.exists(path).catch(() => true)) {
158 // ponytail: one file per month; past 4 MiB (thousands of sessions) it can't be read and stops updating.
159 const read = await $.fs.read(path).then(t => JSON.parse(t) as Rollup).catch(() => undefined)
160 if (!read) continue // never overwrite what could not be read
161 cur = read
162 }
163 Object.assign(cur.files, files)
164 await $.fs.write(path, JSON.stringify(cur)).catch(() => undefined)
165 }
166}
167
168/**
169 * A mod reads at most 4 MiB per file, but long sessions grow far past that
170 * (most usage sits in them). Those are streamed through `cat` (`type` on
171 * Windows), keeping only the lines the parser reads.
172 */
173async function readLarge($: EngineInterface, path: string): Promise<string | undefined> {
174 const comspec = await $.env.get('ComSpec')
175 const argv = comspec ? [comspec, '/d', '/c', 'type', path.replace(/\//g, '\\')] : ['cat', path]
176 const keep: string[] = []
177 let rest = ''
178 try {
179 const it = $.process.spawn({ argv })[Symbol.asyncIterator]()
180 for (;;) {
181 const step = await it.next()
182 // A failed read must not be cached as a transcript without usage.
183 if (step.done) {
184 if (step.value?.code !== 0) return undefined
185 break
186 }
187 if (step.value.stream !== 'stdout') continue
188 const lines = (rest + step.value.text).split('\n')
189 rest = lines.pop() ?? ''
190 for (const line of lines) if (TRANSCRIPT_LINE.test(line)) keep.push(line)
191 }
192 } catch {
193 return undefined
194 }
195 if (TRANSCRIPT_LINE.test(rest)) keep.push(rest)
196 return keep.join('\n')
197}
198
199/** Rebuilds usage from all sessions' transcripts (cached per file), then other sessions' logs. */
200async function scan($: EngineInterface) {
201 if (scanning || !base) return
202 scanning = true
203 try {
204 const startedAt = await $.clock.now()
205 const cachePath = `${folder}/history-cache.json`
206 const loaded = await $.fs
207 .read(cachePath)
208 .then(t => JSON.parse(t) as HistoryCache)
209 .catch(() => undefined)
210 const cache: HistoryCache = loaded?.version === 3 ? loaded : { version: 3, files: {} }
211 const next: HistoryCache = { version: 3, files: {} }
212 const buckets: Buckets = {}
213 const hits: Hit[] = []
214 const fresh: Record<string, { project: string; days: Record<string, DayRow> }> = {}
215 let files = 0
216 let skipped = 0
217
218 const walk = async (dir: string, depth: number): Promise<void> => {
219 for (const entry of await $.fs.list(dir).catch(() => [])) {
220 const path = `${dir}/${entry.name}`
221 if (entry.kind === 'dir' && depth < 3) await walk(path, depth + 1)
222 if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl')) continue
223 if (startedAt - entry.mtimeMs > KEEP_MS) continue
224 const old = cache.files[path]
225 let cached: FileCache
226 if (old && old.size === entry.size && old.mtimeMs === entry.mtimeMs) {
227 cached = old
228 } else {
229 const text = entry.size > MAX_READ ? await readLarge($, path) : await $.fs.read(path).catch(() => '')
230 if (text === undefined) {
231 skipped += 1
232 continue
233 }
234 const tr = parseTranscript(text)
235 const b: Buckets = {}
236 for (const turn of tr.turns) addToBuckets(b, turn)
237 cached = { size: entry.size, mtimeMs: entry.mtimeMs, buckets: b, hits: tr.hits }
238 fresh[path] = { project: projectOf(text, path), days: rollupTranscript(tr) }
239 }
240 files += 1
241 next.files[path] = cached
242 mergeBuckets(buckets, cached.buckets)
243 hits.push(...cached.hits)
244 }
245 }
246 await walk(`${base}/projects`, 0)
247 // Claude Code deletes transcripts after 30 days by default. Their usage and
248 // limit hits stay cached until they are older than the look-back, so the
249 // forecast can compare with up to 9 past weeks.
250 for (const [path, old] of Object.entries(cache.files)) {
251 if (next.files[path] || Math.max(...Object.keys(old.buckets).map(Number)) < startedAt - KEEP_MS) continue
252 next.files[path] = old
253 mergeBuckets(buckets, old.buckets)
254 hits.push(...old.hits)
255 }
256
257 scanBuckets = buckets
258 scanAt = startedAt
259 liveTurns = liveTurns.filter(t => t.t > scanAt)
260 scanInfo = { files, skipped }
261 scanHits = mergeHits(hits)
262 await $.fs.write(cachePath, JSON.stringify(next)).catch(() => undefined)
263 await writeRollups($, fresh)
264 await loadLogs($, startedAt)
265 } finally {
266 scanning = false
267 }
268 await recompute($)
269}
270
271function allBuckets(): Buckets {
272 const b: Buckets = {}
273 mergeBuckets(b, scanBuckets)
274 for (const t of liveTurns) if (t.t > scanAt) addToBuckets(b, t)
275 return b
276}
277
278/** Tuned settings per window: the replay is costly, so it runs at most once an hour. */
279const tuned = new Map<string, { key: string; t: Tuning }>()
280function tuningFor(kind: string, b: Buckets, now: number, horizon: number, opus: number): Tuning {
281 const key = `${Math.floor(now / HOUR)}|${Math.round(horizon / HOUR)}|${opus}`
282 const cur = tuned.get(kind)
283 if (cur?.key === key) return cur.t
284 const t = tune(b, kind, now, horizon)
285 tuned.set(kind, { key, t })
286 return t
287}
288
289async function recompute($: EngineInterface) {
290 const now = await $.clock.now()
291 reportedAt ??= own.readings.reduce<number | undefined>((a, r) => Math.max(a ?? 0, r.t), undefined)
292 const usage = await $.session.usage().catch(() => undefined)
293 const allLimits = usage?.rateLimits ?? []
294 const limits = allLimits.filter(l => KINDS.includes(l.kind))
295 context = usage?.context.tokens ?? context
296 const raw = allBuckets()
297 const logs = allLogs(now)
298 // Past limit hits are exact readings too: 0% at the window's start, 100% when refused.
299 const readings = dedupeReadings([...logs.readings, ...hitReadings(scanHits)])
300 const logged = logs.forecasts
301 // Once the check shows Opus clearly costs more (or less) than assumed, count it so.
302 const check = weightCheck(readings, usageIndex(raw), now)
303 const opus = opusWeight(check)
304 const buckets = reweightOpus(raw, opus)
305 const between = usageIndex(buckets)
306 const prof = profile(buckets, now)
307 const cal: Record<string, Calib> = Object.fromEntries(KINDS.map(kind => [kind, calibrate(readings, between, kind, now)]))
308
309 const tunings: Record<string, Tuning> = {}
310 const forecasts: Forecast[] = limits.map(l => {
311 const resetsAt = l.resetsAt ? Date.parse(l.resetsAt) : undefined
312 const t = tuningFor(l.kind, buckets, now, resetsAt === undefined ? 0 : resetsAt - now, opus)
313 tunings[l.kind] = t
314 return forecast({
315 kind: l.kind,
316 p: l.percentUsed,
317 resetsAt,
318 r: l.resetsAt ? normReset(l.resetsAt) : undefined,
319 now,
320 cal: cal[l.kind],
321 between,
322 readings,
323 prof: t.halfLife === prof.halfLife ? prof : profile(buckets, now, 8, t.halfLife),
324 tune: t,
325 })
326 })
327
328 // One forecast per window and hour is kept, to be scored after the reset.
329 for (const [i, f] of forecasts.entries()) {
330 const key = `${f.kind}|${Math.floor(now / HOUR)}`
331 const reset = limits[i]?.resetsAt
332 const entry = toLog(f, now, reset ? normReset(reset) : undefined)
333 if (!entry || forecastLogged.has(key)) continue
334 forecastLogged.add(key)
335 own.forecasts.push(entry)
336 log(now, forecastEntry(entry))
337 }
338
339 const overall = worst(forecasts.map(f => f.verdict))
340 const worstOne = forecasts.find(f => f.verdict === overall)
341 const warningKey = `${overall}:${worstOne?.kind ?? ''}`
342 const tips = suggestions({ forecasts, between, now, context })
343 if (overall === 'ok') warned = ''
344 else {
345 if (warned !== warningKey) {
346 warned = warningKey
347 tipsLogged = new Set()
348 log(now, { k: 'e', e: 'warn', v: overall, w: worstOne?.kind })
349 }
350 for (const tip of tips.slice(0, 2)) {
351 if (tipsLogged.has(tip.id)) continue
352 tipsLogged.add(tip.id)
353 log(now, { k: 'e', e: 'tip', id: tip.id })
354 }
355 }
356
357 const week = limits.find(l => l.kind === 'seven_day')
358 const kWeek = cal.seven_day?.k
359 const reg = regularity(between, now, prof.since)
360 const next: View = {
361 updatedAt: now,
362 ...(reportedAt !== undefined ? { reportedAt } : {}),
363 overall,
364 warningKey,
365 forecasts,
366 tips,
367 learned: {
368 calib: KINDS.map(kind => ({ label: LABEL[kind] ?? kind, ...cal[kind]!, kind })),
369 ...(check ? { opusCheck: { ...check, applied: opus !== 1 } } : {}),
370 tuning: KINDS.filter(kind => tunings[kind]).map(kind => ({ kind, label: LABEL[kind] ?? kind, ...tunings[kind]! })),
371 ...(reg ? { regularity: reg } : {}),
372 },
373 quality: KINDS.map(kind => ({ label: LABEL[kind] ?? kind, ...evaluate(logged, readings, kind, now) })),
374 history: {
375 scanning,
376 days: Math.floor(prof.days),
377 files: scanInfo.files,
378 skipped: scanInfo.skipped,
379 busiest: prof.days >= 7 ? busiestDays(prof.perHour) : [],
380 pastWeeks: week?.resetsAt && kWeek !== undefined ? pastWeeks(between, Date.parse(week.resetsAt), kWeek, prof.since) : [],
381 hits: hitSummary(limitHits(readings.filter(r => r.p >= 99.5), scanHits)),
382 },
383 folder,
384 }
385 await update($, view, () => next)
386
387
388 // Speak up only when things get worse, or a threshold is crossed.
389 if (isWorse(overall, prevOverall) && worstOne) $.ui.toast(worstOne.headline, { timeoutMs: 8000 })
390 prevOverall = overall
391 for (const l of allLimits) {
392 for (const mark of [80, 90]) {
393 const key = `${l.kind}:${l.resetsAt}:${mark}`
394 if (l.percentUsed >= mark && !notified.has(key)) {
395 notified.add(key)
396 $.ui.toast(`${SHORT[l.kind] ?? l.kind} limit at ${fmtPct(l.percentUsed)}`)
397 }
398 }
399 }
400}
401
402/** Writes the export folder from every log and rollup there is. */
403async function exportAll($: EngineInterface): Promise<string> {
404 await flush($)
405 const now = await $.clock.now()
406 const all = emptyLog()
407 const rollups: Rollup[] = []
408 let skipped = 0
409 for (const entry of await $.fs.list(folder).catch(() => [])) {
410 const name = logName(entry.name)
411 const isRollup = /^rollup-\d{4}-\d{2}\.json$/.test(entry.name)
412 if (!name && !isRollup) continue
413 if (entry.size > MAX_READ) {
414 skipped += 1
415 continue
416 }
417 const text = await $.fs.read(`${folder}/${entry.name}`).catch(() => '')
418 if (name) parseLog(text, name.session, all)
419 else {
420 try {
421 rollups.push(JSON.parse(text) as Rollup)
422 } catch {
423 skipped += 1
424 }
425 }
426 }
427 all.readings = dedupeReadings(all.readings)
428 const out = `${folder}/export`
429 for (const [file, text] of Object.entries(buildExport({ log: all, rollups, now }))) await $.fs.write(`${out}/${file}`, text)
430 log(now, { k: 'e', e: 'export' })
431 return `Exported to ${out}${skipped ? ` (${skipped} files skipped: over 4 MiB or unreadable)` : ''}.`
432}
433
434const verdictColor = (verdict: Verdict) => (verdict === 'ok' ? 'success' : verdict === 'slow' ? 'warning' : 'error')
435const TONE = { good: 'success', warn: 'warning', bad: 'error' } as const
436
437/** How each part of a bar is drawn, in the window's verdict color. */
438const barTint = (verdict: Verdict): Record<BarPart['kind'], { color?: string; dimColor?: boolean; bold?: boolean }> => ({
439 used: { color: verdictColor(verdict) },
440 likely: { color: verdictColor(verdict), dimColor: true },
441 range: { color: 'subtle' },
442 free: { color: 'subtle', dimColor: true },
443 limit: { bold: true },
444})
445
446/** Cells of the small bar in the limits line, left out when the line would not fit. */
447const LINE_BAR = 10
448
449const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Usage limits' })
450
451const pct1 = (x: number) => `${Math.round(x * 100)}%`
452const num = (x: number, d = 1) => x.toFixed(d)
453
454export const register: Register = on => {
455 on('session.start', async ($, e, next) => {
456 await $.command.register({ name: 'limits-forecast', description: 'Usage limits: forecast, history and suggestions ("/limits-forecast text" prints it, "/limits-forecast export" writes CSVs)' })
457 await init($)
458 // The limits line is drawn in the band above the prompt now.
459 $.ui.status(undefined)
460 timer?.cancel()
461 // Every few minutes: write the log, pick up other sessions' usage, recompute.
462 timer = $.clock.every(FLUSH_MS, () => {
463 void flush($)
464 .then(() => scan($))
465 .catch(() => undefined)
466 })
467 $.clock.after(1000, () => {
468 void scan($).catch(() => undefined)
469 })
470 return next(e)
471 })
472
473 on('session.end', async ($, e, next) => {
474 timer?.cancel()
475 await flush($)
476 return next(e)
477 })
478
479 on('turn.complete', async ($, e, next) => {
480 const result = await next(e)
481 const u = e.usage
482 const now = await $.clock.now()
483 const sub = e.agentId !== undefined
484 const entry: Record<string, unknown> = { k: 't', d: e.durationMs, s: sub ? 1 : 0 }
485 if (e.isAborted) entry.a = 1
486 if (!sub && context !== undefined) entry.x = context
487 if (u) {
488 const turn: Turn = {
489 t: now,
490 model: u.model,
491 in: u.input_tokens,
492 cw: u.cache_creation_input_tokens,
493 cr: u.cache_read_input_tokens,
494 out: u.output_tokens,
495 sub,
496 }
497 liveTurns.push(turn)
498 Object.assign(entry, { m: turn.model, i: turn.in, cw: turn.cw, cr: turn.cr, o: turn.out })
499 }
500 log(now, entry)
501 return result
502 })
503
504 on('session.compact', async ($, e, next) => {
505 const before = context
506 const result = await next(e)
507 log(await $.clock.now(), { k: 'e', e: 'compact', ...(before !== undefined ? { x: before } : {}) })
508 return result
509 })
510
511 on('session.measure', async ($, e, next) => {
512 const now = await $.clock.now()
513 if (e.context.tokens !== undefined) context = e.context.tokens
514 if (e.rateLimits.length) reportedAt = now
515 for (const l of e.rateLimits) {
516 const r = l.resetsAt ? normReset(l.resetsAt) : undefined
517 const reading: Reading = { t: now, kind: l.kind, p: l.percentUsed, r }
518 const last = lastLive[l.kind]
519 if (last && last.p === reading.p && last.r === reading.r) continue
520 lastLive[l.kind] = reading
521 own.readings.push(reading)
522 log(now, { k: 'r', w: l.kind, p: l.percentUsed, r })
523 }
524 if (e.changed.includes('rateLimits')) await recompute($)
525 else if (e.rateLimits.length) {
526 // Same percent, fresh report: only the time moves.
527 await update($, view, v => (v ? { ...v, reportedAt: now } : v))
528 }
529 return next(e)
530 })
531
532 on('command.run', { command: 'limits-forecast' }, async ($, e) => {
533 if (e.args.trim() === 'export') return { text: await exportAll($) }
534 const asText = e.args.trim() === 'text' || !drawsUi
535 const opened = asText ? undefined : await openPane($)
536 log(await $.clock.now(), { k: 'e', e: asText ? 'text' : 'pane' })
537 const v = await read($, view)
538 if (!scanning && (v === null || (await $.clock.now()) - scanAt > 30 * 60_000)) {
539 $.clock.after(0, () => {
540 void scan($).catch(() => undefined)
541 })
542 } else {
543 await recompute($)
544 }
545 // Where no pane shows, the same report as text.
546 if (opened?.isPlaced !== false && !asText) return { text: 'Usage limits pane opened.' }
547 return { text: reportText(await read($, view)) }
548 })
549
550 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
551 drawsUi = true
552 const v = await read($, view)
553 if (e.props.hasSurvey || !v || v.forecasts.length === 0) return next(e)
554 const { Box, Button, Text } = $.ui.resolve(e)
555 // Always one line, each verdict in its own color; the warning only under pressure.
556 // The small bars go when the line would not fit.
557 const plain = statusText(v.forecasts, v.reportedAt) ?? ''
558 const bars = plain.length + v.forecasts.length * (LINE_BAR + 2) <= e.props.bodyColumns
559 const line = (
560 <Box flexDirection="row">
561 {v.forecasts.map((f, i) => {
562 const s = statusParts(f)
563 return (
564 <Box flexDirection="row" flexShrink={0}>
565 {i > 0 && <Text dimColor>{' │ '}</Text>}
566 <Text dimColor>{`${s.name} `}</Text>
567 {bars && rangeBar(f, LINE_BAR).map(part => <Text {...barTint(f.verdict)[part.kind]}>{part.text}</Text>)}
568 {bars && <Text> </Text>}
569 <Text bold>{s.pct}</Text>
570 <Text dimColor>{' ↻ '}</Text>
571 <Text>{s.reset}</Text>
572 <Text dimColor>{' → '}</Text>
573 <Text bold={s.tone !== undefined && s.tone !== 'good'} {...(s.tone ? { color: TONE[s.tone] } : { dimColor: true })}>{s.ahead}</Text>
574 {s.range && <Text dimColor>{` (${s.range})`}</Text>}
575 <Text dimColor>{' risk '}</Text>
576 <Text dimColor={s.risk === '–'}>{s.risk}</Text>
577 <Text color={verdictColor(f.verdict)}>{' ● '}</Text>
578 <Text bold color={verdictColor(f.verdict)}>{s.verdict}</Text>
579 </Box>
580 )
581 })}
582 {v.reportedAt !== undefined && <Text dimColor>{` · ${hhmm(v.reportedAt)}`}</Text>}
583 </Box>
584 )
585 if (v.overall === 'ok' || (await read($, hiddenKey)) === v.warningKey) return line
586 const head = v.forecasts.find(f => f.verdict === v.overall)
587 return (
588 <Box flexDirection="column">
589 {line}
590 <Text bold color={v.overall === 'hold' ? 'error' : 'warning'}>
591 {v.overall === 'hold' ? 'Hold on: ' : 'Slow down: '}
592 {head?.headline ?? ''}
593 </Text>
594 {v.tips.slice(0, 2).map(tip => (
595 <Text dimColor>• {tip.text}</Text>
596 ))}
597 <Box flexDirection="row">
598 <Button key="details" label="Details" onPress={() => openPane($)} />
599 <Text> </Text>
600 <Button
601 key="hide"
602 label="Hide"
603 onPress={async () => {
604 log(await $.clock.now(), { k: 'e', e: 'hide', key: v.warningKey })
605 await update($, hiddenKey, () => v.warningKey)
606 }}
607 />
608 </Box>
609 </Box>
610 )
611 })
612
613 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
614 drawsUi = true
615 const { Box, Text } = $.ui.resolve(e)
616 const v = await read($, view)
617 if (!v) return <Text dimColor>Collecting data…</Text>
618 const row = (r: Row) => (
619 <Box flexDirection="row">
620 <Box width={14} flexShrink={0}>
621 <Text dimColor>{` ${r.label}`}</Text>
622 </Box>
623 <Text dimColor={r.dim}>{r.value}</Text>
624 </Box>
625 )
626 const rep = report(v)
627 return (
628 <Box flexDirection="column">
629 <Box marginBottom={1}>
630 <Text dimColor>{rep.note}</Text>
631 </Box>
632 {v.forecasts.map(f => {
633 const c = verdictColor(f.verdict)
634 const tint = barTint(f.verdict)
635 return (
636 <Box flexDirection="column" marginBottom={1}>
637 <Box flexDirection="row">
638 <Box width={14} flexShrink={0}>
639 <Text bold>{f.label}</Text>
640 </Box>
641 <Text bold color={c}>{VERDICT_TEXT[f.verdict]}</Text>
642 </Box>
643 <Box flexDirection="row">
644 <Box width={14} flexShrink={0}>
645 <Text> </Text>
646 </Box>
647 {rangeBar(f).map(part => (
648 <Text {...tint[part.kind]}>{part.text}</Text>
649 ))}
650 </Box>
651 {forecastRows(f, v).map(row)}
652 </Box>
653 )
654 })}
655 {v.forecasts.length > 0 && (
656 <Box marginBottom={1}>
657 <Text dimColor>{LEGEND}</Text>
658 </Box>
659 )}
660
661 <Box flexDirection="column" marginBottom={1}>
662 <Text bold>Suggestions</Text>
663 {v.tips.length === 0 ? <Text dimColor> – nothing to change right now</Text> : v.tips.map(tip => <Text>{` • ${tip.text}`}</Text>)}
664 </Box>
665
666 {rep.sections.map(sec => (
667 <Box flexDirection="column" marginBottom={1}>
668 <Text bold>{sec.title}</Text>
669 {sec.rows.map(row)}
670 </Box>
671 ))}
672 <Text dimColor>{rep.footer}</Text>
673 </Box>
674 )
675 })
676}
677
678type Row = { label: string; value: string; dim?: boolean }
679
680const LEGEND = '█ used ▓ forecast by the reset ▒ 80% range above it │ the limit (100%)'
681
682/** One limit's rows, as the pane and the text report show them. */
683function forecastRows(f: View['forecasts'][number], v: View): Row[] {
684 const week = f.kind === 'seven_day'
685 const rows: Row[] = [
686 { label: 'used', value: `${fmtPct(f.p)} now` },
687 {
688 label: 'resets',
689 value: f.msToReset !== undefined ? `in ${dur(f.msToReset)} · ${clockTime(v.updatedAt + f.msToReset, week)}` : '–',
690 dim: f.msToReset === undefined,
691 },
692 {
693 label: 'at reset',
694 value:
695 f.projected === undefined
696 ? '– learning how your tokens map to percent'
697 : `~${fmtPct(f.projected)}` +
698 (f.lo !== undefined && f.hi !== undefined
699 ? ` · 80% range ${fmtRange(f.lo, f.hi)} (from ${f.samples} past ${f.sampleUnit})`
700 : ` · range needs 3 comparable past ${week ? 'weeks' : 'days'}`),
701 dim: f.projected === undefined,
702 },
703 { label: 'risk', value: f.risk !== undefined ? `${pct1(f.risk)} chance to run out before the reset` : '– comes with the range', dim: f.risk === undefined },
704 { label: 'speed', value: f.rate !== undefined ? `${f.rate.toFixed(1)}%/h over ${f.rateBasis}` : `– ${f.rateBasis}`, dim: f.rate === undefined },
705 {
706 label: 'even pace',
707 value: f.pace !== undefined ? `${fmtPct(f.pace)} by now · you are ${f.p <= f.pace ? 'under' : 'over'} by ${Math.round(Math.abs(f.p - f.pace))}` : '–',
708 dim: f.pace === undefined,
709 },
710 ]
711 if (week) rows.push({ label: 'per day', value: f.perDayLeft !== undefined ? `~${fmtPct(f.perDayLeft)} a day left until the reset` : '–', dim: f.perDayLeft === undefined })
712 return rows
713}
714
715/** The pane's text below the limits: the same for the pane and for `/limits-forecast` where no pane shows. */
716function report(v: View) {
717 const hist = v.history
718 const c = (x: View['learned']['calib'][number]): Row => ({
719 label: x.label,
720 value:
721 (x.k === undefined
722 ? `learning tokens → %: ${String(Math.round(Math.min(x.points, 3) * 10) / 10)} of 3 points seen`
723 : x.seAssumed
724 ? `tokens → % learned, ±${pct1((x.se ?? 0) / x.k)} assumed until 3 stretches (${x.n} so far)`
725 : `tokens → % learned, ±${pct1((x.se ?? 0) / x.k)} (${x.n} stretches)`) +
726 (x.changedAt !== undefined ? ` · re-learned after a change on ${clockTime(x.changedAt, true)}` : ''),
727 dim: x.k === undefined,
728 })
729 return {
730 note:
731 v.forecasts.length > 0
732 ? `As of ${v.reportedAt !== undefined ? clockTime(v.reportedAt, false) : '–'} · updates with every response`
733 : 'No limit reading yet: it arrives with the next response (subscription plans only).',
734 sections: [
735 {
736 title: 'Learning',
737 rows: [
738 ...v.learned.calib.map(c),
739 { label: 'Opus cost', value: opusText(v.learned.opusCheck), dim: !v.learned.opusCheck },
740 ...v.learned.tuning.map(t => ({ label: `${t.label} fit`, value: tuningText(t), dim: t.independent < MIN_REPLAY })),
741 {
742 label: 'your weeks',
743 value: v.learned.regularity
744 ? `vary by ±${pct1(v.learned.regularity.cv)} from week to week (${v.learned.regularity.weeks} weeks)`
745 : '– needs 2 full weeks',
746 dim: !v.learned.regularity,
747 },
748 ],
749 },
750 { title: 'Forecast quality · last 4 weeks', rows: v.quality.map(q => ({ label: q.label, value: qualityText(q), dim: q.n < 3 })) },
751 {
752 title: 'History',
753 rows: [
754 {
755 label: 'read',
756 value: hist.scanning
757 ? 'reading past sessions…'
758 : `${hist.days} days from ${hist.files} transcripts${hist.skipped ? ` (${hist.skipped} could not be read)` : ''}`,
759 },
760 { label: 'limit hits', value: hist.hits.count ? hitText(hist.hits) : '– none in your history', dim: !hist.hits.count },
761 { label: 'busiest', value: hist.busiest.length ? hist.busiest.join(', ') : '– needs a week', dim: !hist.busiest.length },
762 {
763 label: 'past weeks',
764 value: hist.pastWeeks.length ? `${hist.pastWeeks.map(p => fmtPct(p)).join(' · ')} (latest first, estimated)` : '–',
765 dim: !hist.pastWeeks.length,
766 },
767 ],
768 },
769 ] as { title: string; rows: Row[] }[],
770 footer: `Data: ${v.folder} · /limits-forecast export writes CSVs for a retrospective`,
771 }
772}
773
774/** The pane as plain text, for surfaces that draw no plugin panes (VS Code). */
775function reportText(v: View | null): string {
776 if (!v) return 'Usage limits: collecting data…'
777 const rep = report(v)
778 const line = (r: Row) => ` ${r.label.padEnd(12)}${r.value}`
779 const out = [rep.note, '']
780 for (const f of v.forecasts) {
781 out.push(`${f.label.padEnd(14)}${VERDICT_TEXT[f.verdict]}`, ' '.repeat(14) + rangeBar(f).map(p => p.text).join(''), ...forecastRows(f, v).map(line), '')
782 }
783 if (v.forecasts.length) out.push(LEGEND, '')
784 out.push('Suggestions', ...(v.tips.length ? v.tips.map(t => ` • ${t.text}`) : [' – nothing to change right now']), '')
785 for (const sec of rep.sections) out.push(sec.title, ...sec.rows.map(line), '')
786 out.push(rep.footer)
787 return out.join('\n')
788}
789
790const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
791function clockTime(t: number, withDay: boolean) {
792 const d = new Date(t)
793 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
794 return withDay ? `${DAYS[d.getDay()]} ${hm}` : hm
795}
796
797function opusText(c: View['learned']['opusCheck']): string {
798 if (!c) return '– needs more mixed Opus and other use to check'
799 const x = `×${num(c.ratio, 2)} ± ${num(c.se, 2)}`
800 if (Math.abs(c.ratio - 1) <= 2 * c.se) return `weighted as assumed (${x})`
801 return `${c.ratio > 1 ? 'costs more' : 'costs less'} of your limit than assumed (${x})${c.applied ? ', now counted so' : ', not yet precise enough to apply'}`
802}
803
804/** What the replay of past weeks learned, in plain words. */
805function tuningText(t: View['learned']['tuning'][number]): string {
806 if (t.independent < MIN_REPLAY) return `– tunes itself from ${MIN_REPLAY} non-overlapping replayed forecasts (${t.independent} so far)`
807 const parts = [`bursts fade over ${dur(t.tau)}`, `recent ${Math.round(t.halfLife / DAY)} days count half`]
808 if (t.gain !== undefined && t.gain > 0) parts.push(`${pct1(t.gain)} more accurate than the defaults`)
809 if (t.bias !== 1) parts.push(`forecasts ×${num(t.bias, 2)} (they ran ${t.bias < 1 ? 'high' : 'low'})`)
810 if (t.coverage !== undefined) {
811 parts.push(
812 t.scale === 1
813 ? `range held ${pct1(t.coverage)} (aim 80%)`
814 : `range ×${num(t.scale, 2)} so it held ${pct1(t.coverage)} instead of ${pct1(t.coverage0 ?? 0)} (aim 80%)`,
815 )
816 }
817 return `${parts.join(' · ')} (${t.n} replayed, ~${t.independent} independent)`
818}
819
820function qualityText(q: View['quality'][number]): string {
821 if (q.n < 3) return `– ${q.n} forecasts checked so far, scores after 3 resets`
822 const parts = [`off by ±${num(q.mae ?? 0)} pts`]
823 if (q.bias !== undefined && Math.abs(q.bias) >= 3) parts.push(`tends to run ${q.bias > 0 ? 'high' : 'low'}`)
824 if (q.coverage !== undefined) parts.push(`range held ${pct1(q.coverage)} (aim 80%)`)
825 if (q.skill !== undefined) parts.push(`${pct1(Math.abs(q.skill))} ${q.skill >= 0 ? 'better' : 'worse'} than "speed stays the same"`)
826 if (q.brier !== undefined) parts.push(`risk score ${num(q.brier, 2)} (0 best, 0.25 coin toss)`)
827 parts.push(`${q.n} checked`)
828 return parts.join(' · ')
829}
830
831function hitSummary(hits: Hit[]): View['history']['hits'] {
832 const n = (kind: string) => hits.filter(h => h.kind === kind).length
833 return {
834 count: hits.length,
835 fiveHour: n('five_hour'),
836 weekly: n('seven_day'),
837 blockedHours: hits.reduce((a, h) => a + blockedMs(h), 0) / HOUR,
838 ...(hits.length ? { last: hits[hits.length - 1]!.t } : {}),
839 }
840}
841
842function hitText(h: View['history']['hits']): string {
843 const kinds = [h.fiveHour ? `${h.fiveHour}× 5-hour` : '', h.weekly ? `${h.weekly}× weekly` : ''].filter(Boolean).join(', ')
844 const last = h.last !== undefined ? ` · last ${clockTime(h.last, true)} ${new Date(h.last).getDate()}.${new Date(h.last).getMonth() + 1}.` : ''
845 return `${kinds} · ${num(h.blockedHours)} h blocked${last}`
846}
847hooks/model.ts 1026 lines1// Pure logic of the limits mod: no `$`, no I/O, so it can be tested directly.
2
3import { cv, kdeQuantile, kdeTail, mean, ratioFit, ratioOfTwo, slope } from './stats'
4
5export type Verdict = 'ok' | 'slow' | 'hold'
6
7/** One reading of a limit window, as the engine reports it. */
8export type Reading = { t: number; kind: string; p: number; r?: string }
9
10/** One model turn's cost. `sub` marks a subagent's turn. */
11export type Turn = {
12 t: number
13 model: string
14 in: number
15 cw: number
16 cr: number
17 out: number
18 sub?: boolean
19 /** Transcripts only: tool names the message called, effort level, what it is attributed to. */
20 tools?: string[]
21 effort?: string
22 /** `skill:x`, `plugin:x`, `mcp:server`, `agent:type`. */
23 attr?: string[]
24 thinkMs?: number
25 aborted?: boolean
26}
27
28/** Usage summed per 15 minutes: [units, subagent units, opus units]. */
29export type Buckets = Record<string, [number, number, number]>
30
31/** Usage between two times, edge buckets prorated: [units, subagent units, opus units]. */
32export type Between = (from: number, to: number) => [number, number, number]
33
34/** What the mod has learned about converting usage units into percent for one window. */
35export type Calib = {
36 kind: string
37 /** Percent per unit, once enough evidence exists. */
38 k?: number
39 /** Standard error of k, from 3 matched stretches on. */
40 se?: number
41 /** Matched stretches used. */
42 n: number
43 /** Percent points those stretches moved. */
44 points: number
45 /** `se` is an assumed ±25%, not yet measured (fewer than 3 stretches). */
46 seAssumed?: boolean
47 /** Re-learned from recent stretches only after a change from this time on. */
48 changedAt?: number
49}
50
51/** One logged forecast, to be scored once its window has reset. */
52export type ForecastLog = {
53 t: number
54 kind: string
55 r: string
56 p: number
57 /** Point forecast at reset. */
58 pt: number
59 /** 80% prediction interval. */
60 lo?: number
61 hi?: number
62 /** Probability of reaching 100% before reset. */
63 risk?: number
64 n?: number
65 /** Baseline: the current speed held constant. */
66 b?: number
67}
68
69export type Forecast = {
70 kind: string
71 label: string
72 p: number
73 msToReset?: number
74 /** Percent per hour at the current speed. */
75 rate?: number
76 rateBasis: string
77 msToLimit?: number
78 /** Point forecast of the percent at reset. */
79 projected?: number
80 /** 80% prediction interval of the percent at reset. */
81 lo?: number
82 hi?: number
83 /** Probability of reaching the limit before reset. */
84 risk?: number
85 /** How many past stretches the interval and risk come from, and of what. */
86 samples?: number
87 sampleUnit?: 'days' | 'weeks'
88 /** The current speed held constant until reset: the baseline forecasts are scored against. */
89 baseline?: number
90 /** Where an even pace would be right now. */
91 pace?: number
92 /** Percent per day still available until reset (weekly). */
93 perDayLeft?: number
94 verdict: Verdict
95 headline: string
96}
97
98export type Tip = { id: string; text: string }
99
100export const MINUTE = 60_000
101export const HOUR = 60 * MINUTE
102export const DAY = 24 * HOUR
103export const BUCKET = 15 * MINUTE
104export const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
105export const LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
106export const SHORT: Record<string, string> = { five_hour: '5h', seven_day: 'wk' }
107export const VERDICT_TEXT: Record<Verdict, string> = { ok: 'OK', slow: 'SLOW DOWN', hold: 'HOLD ON' }
108
109/** Calibration counts as usable once this many percent points were matched. */
110export const MIN_CALIB_POINTS = 3
111/** Relative error of k assumed until 3 stretches give a measured one. */
112export const ASSUMED_REL_SE = 0.25
113/** Evidence older than this halves in weight. */
114const HALF_LIFE = 14 * DAY
115/** Calibration and scoring look back this far. */
116export const LOOKBACK_DAYS = 28
117/** Shortest stretch between two readings that is matched to usage. */
118const MIN_SPAN: Record<string, number> = { five_hour: HOUR, seven_day: 12 * HOUR }
119/** How long a deviation from the usual pattern is expected to last. */
120const TAU: Record<string, number> = { five_hour: HOUR, seven_day: 12 * HOUR }
121
122const VERDICT_RANK: Record<Verdict, number> = { ok: 0, slow: 1, hold: 2 }
123
124export const worst = (vs: Verdict[]): Verdict =>
125 vs.reduce<Verdict>((a, v) => (VERDICT_RANK[v] > VERDICT_RANK[a] ? v : a), 'ok')
126
127export const isWorse = (a: Verdict, b: Verdict) => VERDICT_RANK[a] > VERDICT_RANK[b]
128
129export const isOpus = (model: string) => model.toLowerCase().includes('opus')
130
131/** Relative price of a model family, used to weight its tokens. */
132export function modelFactor(model: string): number {
133 const m = model.toLowerCase()
134 if (m.includes('opus')) return 5
135 if (m.includes('haiku')) return 1
136 return 3
137}
138
139/**
140 * A turn's cost in "units": tokens weighted like API list prices (cache reads
141 * are cheap, output is expensive, Opus costs more). One unit is about one US
142 * dollar at those prices.
143 */
144export function units(t: Pick<Turn, 'model' | 'in' | 'cw' | 'cr' | 'out'>): number {
145 return (modelFactor(t.model) * (t.in + 1.25 * t.cw + 0.1 * t.cr + 5 * t.out)) / 1e6
146}
147
148export const bucketKey = (t: number) => String(Math.floor(t / BUCKET) * BUCKET)
149
150export function addToBuckets(b: Buckets, turn: Turn) {
151 const k = bucketKey(turn.t)
152 const u = units(turn)
153 const cur = b[k] ?? [0, 0, 0]
154 b[k] = [cur[0] + u, cur[1] + (turn.sub ? u : 0), cur[2] + (isOpus(turn.model) ? u : 0)]
155}
156
157export function mergeBuckets(into: Buckets, from: Buckets) {
158 for (const [k, v] of Object.entries(from)) {
159 const cur = into[k] ?? [0, 0, 0]
160 into[k] = [cur[0] + v[0], cur[1] + v[1], cur[2] + v[2]]
161 }
162}
163
164/** Prefix sums over the buckets, so any range sums in O(log n). */
165export function usageIndex(b: Buckets): Between {
166 const ks = Object.keys(b).map(Number).sort((x, y) => x - y)
167 const vs = ks.map(k => b[String(k)]!)
168 const cum: [number, number, number][] = [[0, 0, 0]]
169 for (const v of vs) {
170 const c = cum[cum.length - 1]!
171 cum.push([c[0] + v[0], c[1] + v[1], c[2] + v[2]])
172 }
173 const lower = (x: number) => {
174 let lo = 0
175 let hi = ks.length
176 while (lo < hi) {
177 const mid = (lo + hi) >> 1
178 if (ks[mid]! < x) lo = mid + 1
179 else hi = mid
180 }
181 return lo
182 }
183 return (from, to) => {
184 if (!(to > from)) return [0, 0, 0]
185 const i0 = lower(from - BUCKET + 1)
186 const i1 = lower(to)
187 if (i1 <= i0) return [0, 0, 0]
188 const a = cum[i0]!
189 const z = cum[i1]!
190 const res: [number, number, number] = [z[0] - a[0], z[1] - a[1], z[2] - a[2]]
191 for (const i of i1 - 1 === i0 ? [i0] : [i0, i1 - 1]) {
192 const start = ks[i]!
193 const outside = 1 - (Math.min(start + BUCKET, to) - Math.max(start, from)) / BUCKET
194 const v = vs[i]!
195 for (let j = 0; j < 3; j++) res[j]! -= v[j]! * outside
196 }
197 return res
198 }
199}
200
201/** Earliest bucket, i.e. how far back the history goes. */
202export const firstBucket = (b: Buckets) => Object.keys(b).reduce((a, k) => Math.min(a, Number(k)), Infinity)
203
204/** A window that ran out: when the first refused request came, and how often it was retried. */
205export type Hit = { t: number; kind: string; r: string; tries: number }
206export type Compaction = { t: number; trigger: string; pre: number; post: number }
207export type Transcript = {
208 turns: Turn[]
209 hits: Hit[]
210 compactions: Compaction[]
211 /** Main-thread turn durations Claude Code recorded. */
212 durations: { t: number; ms: number }[]
213}
214
215/** One spelling per reset time, so readings of the same window group together. */
216export function normReset(r: string): string {
217 const t = Date.parse(r)
218 return Number.isFinite(t) ? new Date(t).toISOString() : r
219}
220
221const ATTRIBUTION: [string, string][] = [
222 ['attributionSkill', 'skill'],
223 ['attributionPlugin', 'plugin'],
224 ['attributionMcpServer', 'mcp'],
225 ['attributionAgent', 'agent'],
226]
227
228/**
229 * Parses a Claude Code transcript (JSONL). Claude Code writes each content
230 * block of a message as its own line with the same usage, so usage is counted
231 * once per message id and tool calls are collected across its lines. Also
232 * picks up refused requests (a limit hit), compactions and turn durations.
233 */
234/** The only transcript lines the parser reads; everything else can be dropped unread. */
235export const TRANSCRIPT_LINE = /"usage"|"quotaLimits"|"compact_boundary"|"turn_duration"/
236
237export function parseTranscript(text: string): Transcript {
238 const byId = new Map<string, Turn>()
239 const hits = new Map<string, Hit>()
240 const out: Transcript = { turns: [], hits: [], compactions: [], durations: [] }
241 for (const line of text.split('\n')) {
242 if (!TRANSCRIPT_LINE.test(line)) continue
243 let d: any
244 try {
245 d = JSON.parse(line)
246 } catch {
247 continue
248 }
249 const t = Date.parse(d?.timestamp)
250 if (!Number.isFinite(t)) continue
251
252 if (d.type === 'system') {
253 const c = d.compactMetadata
254 if (d.subtype === 'compact_boundary' && c) out.compactions.push({ t, trigger: String(c.trigger ?? ''), pre: c.preTokens ?? 0, post: c.postTokens ?? 0 })
255 if (d.subtype === 'turn_duration' && typeof d.durationMs === 'number' && !d.isSidechain) out.durations.push({ t, ms: d.durationMs })
256 continue
257 }
258 if (d.type !== 'assistant') continue
259
260 const q = d.quotaLimits
261 if (d.error === 'rate_limit' && q && typeof q.rateLimitType === 'string' && typeof q.resetsAt === 'number') {
262 const r = new Date(q.resetsAt * 1000).toISOString()
263 const key = `${q.rateLimitType}|${r}`
264 const hit = hits.get(key)
265 if (hit) hit.tries += 1
266 else hits.set(key, { t, kind: q.rateLimitType, r, tries: 1 })
267 continue
268 }
269
270 const m = d.message
271 const u = m?.usage
272 if (!u || !m.model || m.model === '<synthetic>') continue
273 const tools = Array.isArray(m.content)
274 ? m.content.filter((c: any) => c?.type === 'tool_use' && typeof c.name === 'string').map((c: any) => c.name as string)
275 : []
276 const id = String(m.id ?? d.requestId ?? d.uuid ?? '')
277 const seen = id ? byId.get(id) : undefined
278 if (seen) {
279 if (tools.length) seen.tools = [...(seen.tools ?? []), ...tools]
280 if (d.isAbortedMidStream === true) seen.aborted = true
281 continue
282 }
283 const turn: Turn = {
284 t,
285 model: m.model,
286 in: u.input_tokens ?? 0,
287 cw: u.cache_creation_input_tokens ?? 0,
288 cr: u.cache_read_input_tokens ?? 0,
289 out: u.output_tokens ?? 0,
290 sub: d.isSidechain === true,
291 }
292 if (tools.length) turn.tools = tools
293 const effort = d.perTurnEffort ?? d.effort
294 if (typeof effort === 'string') turn.effort = effort
295 const attr = ATTRIBUTION.filter(([k]) => typeof d[k] === 'string' && d[k]).map(([k, tag]) => `${tag}:${d[k]}`)
296 if (attr.length) turn.attr = attr
297 if (typeof d.thinkingDurationMs === 'number') turn.thinkMs = d.thinkingDurationMs
298 if (d.isAbortedMidStream === true) turn.aborted = true
299 if (id) byId.set(id, turn)
300 out.turns.push(turn)
301 }
302 out.hits = [...hits.values()]
303 return out
304}
305
306/** Hits of the same window seen in several transcripts: the first counts, retries add up. */
307export function mergeHits(hits: Hit[]): Hit[] {
308 const by = new Map<string, Hit>()
309 for (const h of [...hits].sort((a, b) => a.t - b.t)) {
310 const key = `${h.kind}|${h.r}`
311 const cur = by.get(key)
312 if (cur) cur.tries += h.tries
313 else by.set(key, { ...h })
314 }
315 return [...by.values()]
316}
317
318/**
319 * A hit is two exact readings: the window started at 0% and stood at 100% when
320 * the request was refused. Calibration learns from them like from live ones.
321 */
322export function hitReadings(hits: Hit[]): Reading[] {
323 return hits.flatMap(h => {
324 const span = WINDOW_MS[h.kind]
325 const end = Date.parse(h.r)
326 if (!span || !Number.isFinite(end)) return []
327 return [
328 { t: end - span, kind: h.kind, p: 0, r: h.r },
329 { t: h.t, kind: h.kind, p: 100, r: h.r },
330 ]
331 })
332}
333
334/**
335 * Readings from all sessions, oldest first. Several sessions log the same move
336 * of a window; the first to see each value is kept.
337 */
338export function dedupeReadings(rs: Reading[]): Reading[] {
339 const seen = new Set<string>()
340 return [...rs]
341 .sort((a, b) => a.t - b.t)
342 .filter(x => {
343 const key = `${x.kind}|${x.r}|${x.p}`
344 if (seen.has(key)) return false
345 seen.add(key)
346 return true
347 })
348}
349
350/**
351 * Consecutive stretches between readings of one window, each at least
352 * MIN_SPAN long. Readings are logged when a window moves a point, so both ends
353 * sit close to a point boundary and the points moved are nearly exact.
354 */
355export function stretches(readings: Reading[], kind: string): [Reading, Reading][] {
356 const byReset = new Map<string, Reading[]>()
357 for (const x of readings) if (x.kind === kind) byReset.set(x.r ?? '', [...(byReset.get(x.r ?? '') ?? []), x])
358 const out: [Reading, Reading][] = []
359 const span = MIN_SPAN[kind] ?? HOUR
360 for (const xs of byReset.values()) {
361 xs.sort((a, b) => a.t - b.t)
362 let start = xs[0]
363 for (const x of xs.slice(1)) {
364 if (!start || x.p < start.p) {
365 start = x
366 continue
367 }
368 if (x.t - start.t >= span) {
369 out.push([start, x])
370 start = x
371 }
372 }
373 }
374 return out
375}
376
377const decay = (age: number, halfLife = HALF_LIFE) => Math.pow(0.5, Math.max(0, age) / halfLife)
378
379/**
380 * Learns percent per unit for one window: each stretch's points moved against
381 * the usage of all sessions in it, a weighted ratio estimate (recent stretches
382 * count more). Stretches with points but no Claude Code usage were spent
383 * elsewhere (claude.ai, another machine) and are left out.
384 */
385export function calibrate(readings: Reading[], between: Between, kind: string, now: number): Calib {
386 let pairs = stretches(readings, kind)
387 .filter(([, b]) => now - b.t <= LOOKBACK_DAYS * DAY)
388 .map(([a, b]) => ({ t: a.t, y: b.p - a.p, x: between(a.t, b.t)[0], w: decay(now - b.t) }))
389 .filter(p => p.x > 0)
390 .sort((a, b) => a.t - b.t)
391 let fit = ratioFit(pairs)
392 let changedAt: number | undefined
393 // A change (a new plan, new limits): the two latest stretches both far off
394 // the fit on the same side. Learn from them alone instead of waiting weeks
395 // for the old ones to fade. Small stretches are coarse (whole points), so
396 // they need a bigger miss.
397 if (fit && pairs.length >= 4) {
398 const k = fit.k
399 const last = pairs.slice(-2)
400 const off = last.map(p => p.y / (k * p.x) - 1)
401 const tol = last.map(p => Math.max(0.3, (3 * (fit?.se ?? 0)) / k, 2 / p.y))
402 if (off.every((o, i) => o > tol[i]!) || off.every((o, i) => o < -tol[i]!)) {
403 pairs = last
404 fit = ratioFit(pairs)
405 changedAt = last[0]!.t
406 }
407 }
408 const points = pairs.reduce((a, p) => a + p.y, 0)
409 const c: Calib = { kind, n: pairs.length, points }
410 if (changedAt !== undefined) c.changedAt = changedAt
411 if (fit && points >= MIN_CALIB_POINTS) {
412 c.k = fit.k
413 // Until 3 stretches give a measured error, assume one rather than none:
414 // a range that treats k as exact is overconfident.
415 if (fit.se !== undefined) c.se = fit.se
416 else {
417 c.se = fit.k * ASSUMED_REL_SE
418 c.seAssumed = true
419 }
420 }
421 return c
422}
423
424/**
425 * Checks the assumed Opus weight: fits the points moved against Opus and other
426 * usage separately. 1 means Opus is weighted right; 1.3 means it costs 30% more
427 * of the limit than assumed.
428 */
429export function weightCheck(readings: Reading[], between: Between, now: number) {
430 const rows = stretches(readings, 'five_hour')
431 .filter(([, b]) => now - b.t <= LOOKBACK_DAYS * DAY)
432 .map(([a, b]) => {
433 const [u, , opus] = between(a.t, b.t)
434 return { y: b.p - a.p, x1: opus, x2: u - opus, w: decay(now - b.t) }
435 })
436 const r = ratioOfTwo(rows)
437 return r && { ...r, n: rows.length }
438}
439
440/**
441 * The checked Opus weight once it clearly differs from the assumed one (more
442 * than 2 standard errors away from 1, and measured to within 25%); else 1.
443 */
444export function opusWeight(check?: { ratio: number; se: number }): number {
445 if (!check || check.se > 0.25 * check.ratio || Math.abs(check.ratio - 1) <= 2 * check.se) return 1
446 return check.ratio
447}
448
449/** Buckets with Opus usage counted `r` times as heavy. */
450export function reweightOpus(b: Buckets, r: number): Buckets {
451 if (r === 1) return b
452 return Object.fromEntries(Object.entries(b).map(([k, [u, s, o]]) => [k, [u + (r - 1) * o, s, o * r]]))
453}
454
455const localDayStart = (t: number) => {
456 const d = new Date(t)
457 d.setHours(0, 0, 0, 0)
458 return d.getTime()
459}
460const nextDay = (t: number) => {
461 const d = new Date(t)
462 d.setDate(d.getDate() + 1)
463 return d.getTime()
464}
465
466export function hourOfWeek(t: number): number {
467 const d = new Date(t)
468 return d.getDay() * 24 + d.getHours()
469}
470
471const isWeekend = (t: number) => {
472 const day = new Date(t).getDay()
473 return day === 0 || day === 6
474}
475
476export type Profile = { perHour: number[]; days: number; since: number; halfLife: number }
477
478/**
479 * Expected units per hour of the week. Factorized: a recency-weighted mean
480 * total per weekday times one hour-of-day shape (smoothed over neighbouring
481 * hours), 7 + 24 numbers instead of 168 sparse ones. Complete days only, idle
482 * days included.
483 */
484export function profile(b: Buckets, now: number, weeks = 8, halfLife = HALF_LIFE): Profile {
485 const since = firstBucket(b)
486 const empty = { perHour: new Array(168).fill(0), days: 0, since: now, halfLife }
487 if (!Number.isFinite(since)) return empty
488 const today = localDayStart(now)
489 let day = localDayStart(Math.max(since, now - weeks * 7 * DAY))
490 if (day < since) day = nextDay(day)
491 const totals = new Map<number, number>()
492 for (let d = day; d < today; d = nextDay(d)) totals.set(d, 0)
493 const hours = new Array(24).fill(0)
494 for (const [k, v] of Object.entries(b)) {
495 const t = Number(k)
496 if (t < day || t >= today) continue
497 const d = localDayStart(t)
498 totals.set(d, (totals.get(d) ?? 0) + v[0])
499 hours[new Date(t).getHours()] += v[0] * decay(now - t, halfLife)
500 }
501
502 const sumW = new Array(7).fill(0)
503 const sumWT = new Array(7).fill(0)
504 let allW = 0
505 let allWT = 0
506 for (const [d, total] of totals) {
507 const w = decay(now - d, halfLife)
508 const dow = new Date(d).getDay()
509 sumW[dow] += w
510 sumWT[dow] += w * total
511 allW += w
512 allWT += w * total
513 }
514 const overall = allW > 0 ? allWT / allW : 0
515 const dayMean = sumW.map((w, i) => (w > 0 ? sumWT[i] / w : overall))
516
517 const smooth = hours.map((_, h) => 0.25 * hours[(h + 23) % 24] + 0.5 * hours[h] + 0.25 * hours[(h + 1) % 24])
518 const total = smooth.reduce((a, x) => a + x, 0)
519 const shape = smooth.map(x => (total > 0 ? x / total : 1 / 24))
520
521 const perHour = new Array(168).fill(0).map((_, i) => (dayMean[Math.floor(i / 24)] ?? 0) * (shape[i % 24] ?? 0))
522 return { perHour, days: (now - since) / DAY, since, halfLife }
523}
524
525/** Expected units from `from` to `to` following the profile. */
526export function expectedUnits(perHour: number[], from: number, to: number): number {
527 let sum = 0
528 for (let t = Math.floor(from / HOUR) * HOUR; t < to; t += HOUR) {
529 const overlap = Math.min(t + HOUR, to) - Math.max(t, from)
530 if (overlap > 0) sum += ((perHour[hourOfWeek(t)] ?? 0) * overlap) / HOUR
531 }
532 return sum
533}
534
535/**
536 * The same stretch (now → end) in the past: earlier weeks for the weekly
537 * window, earlier days of the same kind (workday or weekend) for the 5-hour one.
538 */
539export function pastStretches(between: Between, kind: string, now: number, end: number, since: number): number[] {
540 const out: number[] = []
541 if (kind === 'seven_day') {
542 for (let i = 1; i <= 9; i++) {
543 const s = now - i * 7 * DAY
544 if (s < since) break
545 out.push(between(s, end - i * 7 * DAY)[0])
546 }
547 } else {
548 for (let d = 1; d <= 28 && out.length < 14; d++) {
549 const s = now - d * DAY
550 if (s < since) break
551 if (isWeekend(s) === isWeekend(now)) out.push(between(s, end - d * DAY)[0])
552 }
553 }
554 return out
555}
556
557/** Percent per hour from readings of one window: least-squares slope, if they span long enough. */
558export function readingRate(readings: Reading[], kind: string, r: string | undefined, now: number, lookback: number) {
559 const xs = readings.filter(x => x.kind === kind && x.r === r && x.t >= now - lookback && x.t <= now)
560 if (xs.length < 3) return undefined
561 const ts = xs.map(x => x.t)
562 if (Math.max(...ts) - Math.min(...ts) < lookback / 4) return undefined
563 const s = slope(ts, xs.map(x => x.p))
564 return s === undefined ? undefined : Math.max(0, s * HOUR)
565}
566
567export type ForecastInput = {
568 kind: string
569 p: number
570 resetsAt?: number
571 r?: string
572 now: number
573 cal?: Pick<Calib, 'k' | 'se'>
574 between: Between
575 readings: Reading[]
576 prof?: Profile
577 tune?: Tuning
578}
579
580/**
581 * Expected usage (units) from `now` to `end`: the usual pattern, plus the
582 * recent deviation from it fading out over `tau` (mean reversion), since a
583 * burst does not last until the reset. The deviation is measured over the
584 * last hour (5-hour window) or day (week).
585 */
586export function expectedBase(prof: Profile, between: Between, kind: string, now: number, end: number, tau = TAU[kind] ?? HOUR): number {
587 const lookback = kind === 'seven_day' ? DAY : HOUR
588 const lbH = lookback / HOUR
589 const tauH = tau / HOUR
590 const usual = expectedUnits(prof.perHour, now, end)
591 const current = between(now - lookback, now)[0] / lbH
592 const usualThen = expectedUnits(prof.perHour, now - lookback, now) / lbH
593 return Math.max(0, usual + (current - usualThen) * tauH * (1 - Math.exp(-(end - now) / HOUR / tauH)))
594}
595
596/** One outcome (units) per past stretch: the forecast plus that stretch's deviation from normal, scaled. */
597export function scenarios(base: number, past: number[], scale = 1): number[] {
598 const m = mean(past)
599 return past.map(u => Math.max(0, base + scale * (u - m)))
600}
601
602/** Forecast settings, tuned by replaying past weeks. */
603export type Tuning = {
604 /** How long a deviation from the usual pattern lasts. */
605 tau: number
606 /** Recency half-life of the weekly profile. */
607 halfLife: number
608 /** Width of the range relative to the past spread. */
609 scale: number
610 /** Forecasts are multiplied by this: replayed actual ÷ forecast. */
611 bias: number
612 /** Replayed forecasts. */
613 n: number
614 /** Of those, about how many do not overlap: the time they span ÷ the horizon. */
615 independent: number
616 /** How much smaller the point error got than with the defaults: 0.12 = 12%. */
617 gain?: number
618 /** Share of replayed outcomes inside the 80% range, with the default width and with the tuned one. */
619 coverage0?: number
620 coverage?: number
621}
622
623export const defaultTuning = (kind: string): Tuning => ({ tau: TAU[kind] ?? HOUR, halfLife: HALF_LIFE, scale: 1, bias: 1, n: 0, independent: 0 })
624
625/**
626 * Non-overlapping replayed forecasts needed before tuned settings are used.
627 * Replayed moments a few hours apart share most of their future, so they
628 * count as one.
629 */
630export const MIN_REPLAY = 10
631
632/**
633 * Learns the forecast settings by replaying the past: at many past moments,
634 * forecasts the usage until `horizon` later from what was known then (rolling
635 * origin), and compares with what happened. In usage units, so no past limit
636 * readings are needed. Picks the fade-out time and profile half-life with the
637 * smallest error, then the bias, then the range width that held 80% of
638 * outcomes. A setting only replaces its default when it is clearly better.
639 */
640export function tune(b: Buckets, kind: string, now: number, horizon: number): Tuning {
641 const t0 = defaultTuning(kind)
642 const between = usageIndex(b)
643 const since = firstBucket(b)
644 if (!Number.isFinite(since) || !(horizon > 0)) return t0
645 const isWeek = kind === 'seven_day'
646 const step = isWeek ? 6 * HOUR : 2 * HOUR
647 const taus = (isWeek ? [3, 12, 24, 48] : [0.25, 1, 2, 4]).map(h => h * HOUR)
648 const lives = [7, 14, 28].map(d => d * DAY)
649
650 const cases: { o: number; actual: number; past: number[]; base: number[][] }[] = []
651 const start = Math.max(since + (isWeek ? 21 : 3) * DAY, now - (isWeek ? 63 : LOOKBACK_DAYS) * DAY)
652 for (let o = Math.ceil(start / step) * step; o + horizon <= now; o += step) {
653 const end = o + horizon
654 const past = pastStretches(between, kind, o, end, since)
655 if (past.length < 3) continue
656 const actual = between(o, end)[0]
657 const base = lives.map(hl => {
658 const prof = profile(b, o, 8, hl)
659 return taus.map(tau => expectedBase(prof, between, kind, o, end, tau))
660 })
661 // Idle and expected idle: says nothing about the settings.
662 if (actual === 0 && base.every(row => row.every(x => x === 0))) continue
663 cases.push({ o, actual, past, base })
664 }
665 const n = cases.length
666 const independent = n && Math.min(n, Math.floor((cases[n - 1]!.o - cases[0]!.o) / horizon) + 1)
667 if (independent < MIN_REPLAY) return { ...t0, n, independent }
668
669 const mae = (li: number, ti: number) => mean(cases.map(c => Math.abs(c.base[li]![ti]! - c.actual)))
670 const def: [number, number] = [lives.indexOf(HALF_LIFE), taus.indexOf(t0.tau)]
671 const defErr = mae(...def)
672 let best = def
673 let bestErr = defErr
674 for (let li = 0; li < lives.length; li++) {
675 for (let ti = 0; ti < taus.length; ti++) {
676 const e = mae(li, ti)
677 if (e < bestErr) [best, bestErr] = [[li, ti], e]
678 }
679 }
680 // ponytail: a fixed 5% margin stands in for a significance test (Diebold-Mariano);
681 // replayed moments overlap, so a small gain over a few weeks is easily noise.
682 if (bestErr > 0.95 * defErr) [best, bestErr] = [def, defErr]
683 const [li, ti] = best
684
685 const sumF = cases.reduce((a, c) => a + c.base[li]![ti]!, 0)
686 const ratio = sumF > 0 ? clamp(cases.reduce((a, c) => a + c.actual, 0) / sumF, 0.67, 1.5) : 1
687 const bias = Math.abs(ratio - 1) < 0.05 ? 1 : ratio
688
689 const cover = (scale: number) =>
690 mean(cases.map(c => {
691 const s = scenarios(c.base[li]![ti]! * bias, c.past, scale)
692 return c.actual >= kdeQuantile(s, 0.1) && c.actual <= kdeQuantile(s, 0.9) ? 1 : 0
693 }))
694 // Nearest to 1 first, so a tie keeps the width closer to the past spread.
695 const scales = [1, 1.25, 0.75, 1.5, 2, 0.5, 2.5, 3, 4]
696 const covs = scales.map(cover)
697 let si = 0
698 for (let i = 1; i < scales.length; i++) if (Math.abs(covs[i]! - 0.8) < Math.abs(covs[si]! - 0.8)) si = i
699
700 return {
701 tau: taus[ti]!,
702 halfLife: lives[li]!,
703 scale: scales[si]!,
704 bias,
705 n,
706 independent,
707 gain: defErr > 0 ? 1 - bestErr / defErr : 0,
708 coverage0: covs[0]!,
709 coverage: covs[si]!,
710 }
711}
712
713export function forecast(x: ForecastInput): Forecast {
714 const L = WINDOW_MS[x.kind]
715 const label = LABEL[x.kind] ?? x.kind
716 const msToReset = x.resetsAt !== undefined ? Math.max(0, x.resetsAt - x.now) : undefined
717 const isWeek = x.kind === 'seven_day'
718 // The 5-hour window is judged on the last hour; the week on the last day,
719 // since nobody works 24 hours straight.
720 const lookback = isWeek ? DAY : HOUR
721 const k = x.cal?.k
722
723 let rate: number | undefined
724 let rateBasis = 'not enough data yet'
725 if (k !== undefined) {
726 rate = (x.between(x.now - lookback, x.now)[0] * k) / (lookback / HOUR)
727 rateBasis = isWeek ? 'your last 24 hours' : 'your last hour'
728 } else {
729 const rr = readingRate(x.readings, x.kind, x.r, x.now, lookback)
730 if (rr !== undefined) {
731 rate = rr
732 rateBasis = isWeek ? 'your last 24 hours' : 'your last hour'
733 }
734 }
735
736 const pace = L && msToReset !== undefined ? clamp((1 - msToReset / L) * 100, 0, 100) : undefined
737 const hoursToReset = msToReset !== undefined ? msToReset / HOUR : undefined
738 const baseline = rate !== undefined && hoursToReset !== undefined ? x.p + rate * hoursToReset : undefined
739 const msToLimit = rate !== undefined && rate > 0 ? ((100 - x.p) / rate) * HOUR : undefined
740
741 let projected = baseline
742 let lo: number | undefined
743 let hi: number | undefined
744 let risk: number | undefined
745 let samples: number | undefined
746 if (k !== undefined && x.prof && x.prof.days >= 3 && x.resetsAt !== undefined && hoursToReset !== undefined) {
747 // Settings the replay of past weeks tuned (or the defaults).
748 const t = x.tune ?? defaultTuning(x.kind)
749 const base = expectedBase(x.prof, x.between, x.kind, x.now, x.resetsAt, t.tau) * t.bias
750 projected = x.p + k * base
751
752 // Spread: how the same stretch varied in the past, one scenario per past
753 // stretch, smoothed (kernel density) and widened by the uncertainty of k.
754 // The 80% range and the risk both come from that one distribution.
755 const past = pastStretches(x.between, x.kind, x.now, x.resetsAt, x.prof.since)
756 if (past.length >= 3) {
757 const sims = scenarios(base, past, t.scale).map(u => x.p + k * u)
758 const kSd = (x.cal?.se ?? 0) * base
759 lo = Math.max(x.p, kdeQuantile(sims, 0.1, kSd))
760 hi = kdeQuantile(sims, 0.9, kSd)
761 risk = kdeTail(sims, 100, kSd)
762 samples = past.length
763 }
764 }
765
766 const perDayLeft = isWeek && msToReset !== undefined && msToReset > 0
767 ? Math.max(0, 100 - x.p) / Math.max(msToReset / DAY, 1 / 24)
768 : undefined
769
770 // Hard data (the limit itself, or the current speed hitting it soon) can say
771 // "hold on"; a forecast from the usual pattern only ever says "slow down".
772 const near = isWeek ? DAY : 45 * MINUTE
773 const runsOutSoon = msToLimit !== undefined && msToReset !== undefined && msToLimit < msToReset && msToLimit < near
774 const forecastOver = risk !== undefined ? risk > 0.5 : projected !== undefined && projected > 100
775 let verdict: Verdict = 'ok'
776 if (x.p >= 95 || runsOutSoon) verdict = 'hold'
777 else if (forecastOver || x.p >= 85) verdict = 'slow'
778
779 const unit = isWeek ? 'weeks' : 'days'
780 const range = lo !== undefined && hi !== undefined ? ` (80%: ${fmtRange(lo, hi)})` : ''
781 let headline: string
782 if (x.p >= 95) headline = `${label} limit almost used (${fmtPct(x.p)}).`
783 else if (runsOutSoon) headline = `${label}: at this speed you hit the limit in ~${dur(msToLimit!)}, reset is in ${dur(msToReset!)}.`
784 else if (risk !== undefined && risk > 0.5) headline = `${label}: ${fmtPct(risk * 100)} risk of running out before the reset in ${dur(msToReset!)} (compared with ${samples} past ${unit}).`
785 else if (projected !== undefined && projected > 100 && msToLimit !== undefined && msToReset !== undefined) headline = `${label}: at this speed you hit the limit in ~${dur(msToLimit)}, reset is in ${dur(msToReset)}.`
786 else if (projected !== undefined && msToReset !== undefined) headline = `${label}: on track for ~${fmtPct(Math.min(projected, 100))}${range} at reset in ${dur(msToReset)}.`
787 else if (msToReset !== undefined) headline = `${label}: ${fmtPct(x.p)} used, reset in ${dur(msToReset)}.`
788 else headline = `${label}: ${fmtPct(x.p)} used.`
789
790 const f: Forecast = { kind: x.kind, label, p: x.p, rateBasis, verdict, headline }
791 Object.assign(f, strip({ msToReset, rate, msToLimit, projected, lo, hi, risk, samples, baseline, pace, perDayLeft }))
792 if (samples !== undefined) f.sampleUnit = unit
793 return f
794}
795
796const strip = <T extends Record<string, unknown>>(o: T): Partial<T> =>
797 Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>
798
799/** The forecast to log for scoring later, or undefined when there is none. */
800export function toLog(f: Forecast, t: number, r: string | undefined): ForecastLog | undefined {
801 if (f.projected === undefined || !r) return undefined
802 const round = (v?: number) => (v === undefined ? undefined : Math.round(v * 10) / 10)
803 return strip({ t, kind: f.kind, r, p: f.p, pt: round(f.projected)!, lo: round(f.lo), hi: round(f.hi), risk: round(f.risk), n: f.samples, b: round(f.baseline) }) as ForecastLog
804}
805
806export type Quality = {
807 kind: string
808 /** Forecasts scored (windows that have reset). */
809 n: number
810 /** Mean absolute error of the point forecast, in percent points. */
811 mae?: number
812 /** Mean error: positive means forecasts ran high. */
813 bias?: number
814 /** Share of outcomes inside the 80% interval: honest intervals hit about 80%. */
815 coverage?: number
816 nInterval: number
817 /** Mean width of the 80% interval (sharpness), in points. */
818 width?: number
819 /** Brier score of the risk of running out: 0 is perfect, 0.25 a coin toss. */
820 brier?: number
821 nRisk: number
822 /** 1 − MAE / MAE of the constant-speed baseline: above 0 beats it. */
823 skill?: number
824}
825
826/**
827 * Scores logged forecasts against what happened: the final percent of each
828 * window that has reset. Several sessions may log the same forecast, so one
829 * per window and hour counts.
830 */
831export function evaluate(logs: ForecastLog[], readings: Reading[], kind: string, now: number, since = now - LOOKBACK_DAYS * DAY): Quality {
832 const final = new Map<string, { p: number; t: number }>()
833 for (const x of readings) {
834 if (x.kind !== kind || !x.r) continue
835 const cur = final.get(x.r)
836 final.set(x.r, { p: Math.max(cur?.p ?? 0, x.p), t: Math.max(cur?.t ?? 0, x.t) })
837 }
838 const seen = new Set<string>()
839 const scored: { f: ForecastLog; y: number }[] = []
840 for (const f of [...logs].sort((a, b) => a.t - b.t)) {
841 if (f.kind !== kind || f.t < since || !(Date.parse(f.r) <= now)) continue
842 const out = final.get(f.r)
843 const key = `${f.r}|${Math.floor(f.t / HOUR)}`
844 if (!out || out.t < f.t || seen.has(key)) continue
845 seen.add(key)
846 scored.push({ f, y: Math.min(out.p, 100) })
847 }
848 const q: Quality = { kind, n: scored.length, nInterval: 0, nRisk: 0 }
849 if (scored.length === 0) return q
850 const err = scored.map(s => Math.min(s.f.pt, 100) - s.y)
851 q.mae = mean(err.map(Math.abs))
852 q.bias = mean(err)
853 const iv = scored.filter(s => s.f.lo !== undefined && s.f.hi !== undefined)
854 q.nInterval = iv.length
855 if (iv.length) {
856 q.coverage = iv.filter(s => s.y >= s.f.lo! - 0.5 && s.y <= Math.min(s.f.hi!, 100) + 0.5).length / iv.length
857 q.width = mean(iv.map(s => Math.min(s.f.hi!, 100) - s.f.lo!))
858 }
859 const rk = scored.filter(s => s.f.risk !== undefined)
860 q.nRisk = rk.length
861 if (rk.length) q.brier = mean(rk.map(s => (s.f.risk! - (s.y >= 99.5 ? 1 : 0)) ** 2))
862 const bl = scored.filter(s => s.f.b !== undefined)
863 const blErr = bl.reduce((a, s) => a + Math.abs(Math.min(s.f.b!, 100) - s.y), 0)
864 if (bl.length && blErr > 0) {
865 q.skill = 1 - bl.reduce((a, s) => a + Math.abs(Math.min(s.f.pt, 100) - s.y), 0) / blErr
866 }
867 return q
868}
869
870/** How regular weekly usage is: coefficient of variation of complete past weeks. */
871export function regularity(between: Between, now: number, since: number) {
872 const weeks: number[] = []
873 for (let i = 1; i <= 8; i++) {
874 const s = now - i * 7 * DAY
875 if (s < since) break
876 weeks.push(between(s, s + 7 * DAY)[0])
877 }
878 const c = cv(weeks)
879 return c === undefined ? undefined : { cv: c, weeks: weeks.length }
880}
881
882export type SuggestionInput = {
883 forecasts: Forecast[]
884 between: Between
885 now: number
886 /** Context size of the main thread, in tokens. */
887 context?: number
888}
889
890export function suggestions(x: SuggestionInput): Tip[] {
891 const out: Tip[] = []
892 const short = x.forecasts.find(f => f.kind === 'five_hour')
893 const week = x.forecasts.find(f => f.kind === 'seven_day')
894 // Tight: not OK, or OK but the forecast or risk says it is close.
895 const tight = (f?: Forecast) => !!f && (f.verdict !== 'ok' || (f.projected ?? 0) >= 100 || (f.risk ?? 0) >= 0.5)
896 const anyPressure = x.forecasts.some(f => tight(f))
897 const [total, sub, opus] = x.between(x.now - 5 * HOUR, x.now)
898
899 if (anyPressure) {
900 if ((x.context ?? 0) > 120_000) {
901 out.push({ id: 'context', text: `Your context is ~${Math.round((x.context ?? 0) / 1000)}k tokens and every message re-sends it: /compact, or /clear when you switch topics.` })
902 }
903 if (total > 0 && opus / total > 0.6) {
904 out.push({ id: 'model', text: 'Most recent usage is Opus. Sonnet for routine work (/model) makes the same limit last about 1.7× longer.' })
905 }
906 if (total > 0 && sub / total > 0.4) {
907 out.push({ id: 'subagents', text: `Subagents made ${Math.round((sub / total) * 100)}% of recent usage: fewer parallel agents slow the burn most.` })
908 }
909 if (short && tight(short) && short.msToReset !== undefined && !tight(week)) {
910 out.push({ id: 'break', text: `The 5-hour window resets in ${dur(short.msToReset)}: a break until then costs nothing from the week.` })
911 }
912 if (week && tight(week) && week.perDayLeft !== undefined) {
913 out.push({ id: 'budget', text: `To last the week, keep to about ${fmtPct(week.perDayLeft)} per day until the reset.` })
914 }
915 } else if (
916 week && week.pace !== undefined && week.p < week.pace - 10 && week.perDayLeft !== undefined &&
917 (week.projected ?? 0) < 90 && (week.risk ?? 0) < 0.25
918 ) {
919 out.push({ id: 'room', text: `You're ${Math.round(week.pace - week.p)} points under an even pace this week: about ${fmtPct(week.perDayLeft)} per day is available, room for bigger tasks.` })
920 }
921 return out
922}
923
924/** Estimated weekly percent of the past weeks, from the current reset backwards. */
925export function pastWeeks(between: Between, resetsAt: number, k: number, since: number, count = 4): number[] {
926 const res: number[] = []
927 for (let i = 1; i <= count; i++) {
928 const end = resetsAt - i * 7 * DAY
929 if (end - 7 * DAY < since) break
930 res.push(between(end - 7 * DAY, end)[0] * k)
931 }
932 return res
933}
934
935/** The busiest days of the week in the profile, by name. */
936export function busiestDays(perHour: number[], n = 2): string[] {
937 const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
938 const perDay = names.map((_, d) => perHour.slice(d * 24, d * 24 + 24).reduce((a, x) => a + x, 0))
939 if (perDay.every(x => x === 0)) return []
940 return perDay.map((v, d) => ({ v, d })).sort((a, b) => b.v - a.v).slice(0, n).map(x => names[x.d] ?? '')
941}
942
943export const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x))
944
945export const fmtPct = (p: number) => `${Math.round(p)}%`
946
947export const fmtRange = (lo: number, hi: number) => `${Math.round(lo)}–${fmtPct(hi)}`
948
949export function dur(ms: number): string {
950 const m = Math.max(0, Math.round(ms / 60_000))
951 if (m < 60) return `${m}m`
952 const h = Math.floor(m / 60)
953 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`
954 return h % 24 ? `${Math.floor(h / 24)}d ${h % 24}h` : `${h / 24}d`
955}
956
957/**
958 * The limits line as plain text: the same fields for every window, OK or not,
959 * in the same order, then when Claude Code last reported them. A dash stands
960 * for what is not known yet.
961 * 5h 42% ↻ 2h30 → 68% (61–77) risk 0% ● OK │ wk 61% ↻ 3d → 104% (88–119) risk 75% ● SLOW DOWN · 14:02
962 */
963export function statusText(forecasts: Forecast[], at?: number): string | undefined {
964 if (forecasts.length === 0) return undefined
965 const parts = forecasts.map(f => {
966 const s = statusParts(f)
967 return `${s.name} ${s.pct} ↻ ${s.reset} → ${s.ahead}${s.range ? ` (${s.range})` : ''} risk ${s.risk} ● ${s.verdict}`
968 })
969 return parts.join(' │ ') + (at === undefined ? '' : ` · ${hhmm(at)}`)
970}
971
972/**
973 * One window's fields, apart so they can be drawn in their own colors. `tone`
974 * colors the forecast: bad over 100%, warn when it is near or its range
975 * reaches past the limit.
976 */
977export function statusParts(f: Forecast) {
978 const tone: 'good' | 'warn' | 'bad' | undefined = f.projected === undefined
979 ? undefined
980 : f.projected > 100 ? 'bad' : f.projected >= 90 || (f.hi ?? 0) > 100 ? 'warn' : 'good'
981 return {
982 name: SHORT[f.kind] ?? f.kind,
983 pct: fmtPct(f.p),
984 reset: f.msToReset === undefined ? '–' : dur(f.msToReset),
985 ahead: f.projected === undefined ? '–' : fmtPct(f.projected),
986 range: f.lo !== undefined && f.hi !== undefined ? `${Math.round(f.lo)}–${Math.round(f.hi)}` : undefined,
987 risk: f.risk === undefined ? '–' : fmtPct(f.risk * 100),
988 verdict: VERDICT_TEXT[f.verdict],
989 tone,
990 }
991}
992
993/** Local clock time, `14:02`. */
994export const hhmm = (t: number) => {
995 const d = new Date(t)
996 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
997}
998
999export type BarPart = { kind: 'used' | 'likely' | 'range' | 'free' | 'limit'; text: string }
1000
1001/**
1002 * A bar from 0 to 125% (25 cells of 5% by default) with the limit marked at
1003 * 100%: what is used, what the forecast adds by the reset, and the 80% range
1004 * above the forecast.
1005 */
1006export function rangeBar(f: Pick<Forecast, 'p' | 'projected' | 'hi'>, cells = 25): BarPart[] {
1007 const CH = { used: '█', likely: '▓', range: '▒', free: '·', limit: '│' } as const
1008 // ▓ up to the forecast, ▒ from there to the top of the range: the side
1009 // that decides whether the limit is reached (a fan chart in one row).
1010 const likelyTo = f.projected ?? f.p
1011 const rangeTo = f.hi ?? f.projected ?? f.p
1012 const parts: BarPart[] = []
1013 const push = (kind: BarPart['kind']) => {
1014 const last = parts[parts.length - 1]
1015 if (last && last.kind === kind) last.text += CH[kind]
1016 else parts.push({ kind, text: CH[kind] })
1017 }
1018 const step = 125 / cells
1019 for (let i = 0; i < cells; i++) {
1020 if (i === Math.round(cells * 0.8)) push('limit')
1021 const mid = (i + 0.5) * step
1022 push(mid < f.p ? 'used' : mid < likelyTo ? 'likely' : mid < rangeTo ? 'range' : 'free')
1023 }
1024 return parts
1025}
1026hooks/retro.ts 369 lines1// Retrospective data, pure: the log format, daily rollups of transcripts, and
2// the export for looking back (limit hits, tips followed, forecast quality).
3
4import { evaluate, isOpus, LABEL, mergeHits, normReset, units } from './model'
5import type { Compaction, ForecastLog, Hit, Quality, Reading, Transcript } from './model'
6
7const MINUTE = 60_000
8
9// ── The log ─────────────────────────────────────────────────────────────────
10// One JSON object per line in log-YYYY-MM-<session>.jsonl:
11// r a window moved: { w, p, r }
12// t a turn ended: { m, i, cw, cr, o, s, x?, d, a? } (x context, d duration ms, a aborted)
13// f a forecast: { w, r, p, pt, lo?, hi?, risk?, n?, b? }
14// e an event: { e: warn | hide | pane | tip | compact | export, ... }
15
16export type LoggedTurn = { t: number; s: string; m?: string; i: number; cw: number; cr: number; o: number; sub: boolean; x?: number; d?: number; a: boolean }
17export type LoggedEvent = { t: number; s: string; e: string; [k: string]: unknown }
18export type Log = { readings: Reading[]; forecasts: ForecastLog[]; turns: LoggedTurn[]; events: LoggedEvent[] }
19
20export const emptyLog = (): Log => ({ readings: [], forecasts: [], turns: [], events: [] })
21
22export function forecastEntry(f: ForecastLog): Record<string, unknown> {
23 const { t, kind, ...rest } = f
24 return { t, k: 'f', w: kind, ...rest }
25}
26
27export function parseLog(text: string, session: string, into: Log = emptyLog()): Log {
28 for (const line of text.split('\n')) {
29 if (!line) continue
30 let d: any
31 try {
32 d = JSON.parse(line)
33 } catch {
34 continue
35 }
36 if (typeof d?.t !== 'number') continue
37 if (d.k === 'r') into.readings.push({ t: d.t, kind: d.w, p: d.p, r: typeof d.r === 'string' ? normReset(d.r) : d.r })
38 else if (d.k === 'f') {
39 const { k: _k, w, ...rest } = d
40 into.forecasts.push({ ...rest, kind: w, r: normReset(String(rest.r)) })
41 } else if (d.k === 't') {
42 into.turns.push({ t: d.t, s: session, m: d.m, i: d.i ?? 0, cw: d.cw ?? 0, cr: d.cr ?? 0, o: d.o ?? 0, sub: d.s === 1, x: d.x, d: d.d, a: d.a === 1 })
43 } else if (d.k === 'e') {
44 const { k: _k, ...rest } = d
45 into.events.push({ ...rest, s: session })
46 }
47 }
48 return into
49}
50
51/** `log-2026-10-abcdef12.jsonl` → month and session, or undefined. */
52export function logName(name: string) {
53 const m = /^log-(\d{4}-\d{2})-(.+)\.jsonl$/.exec(name)
54 return m ? { month: m[1]!, session: m[2]! } : undefined
55}
56
57// ── Daily rollups ───────────────────────────────────────────────────────────
58// Claude Code deletes old transcripts (cleanupPeriodDays, 30 by default), so
59// each transcript's usage is kept per day in rollup-YYYY-MM.json, by file.
60
61/**
62 * One transcript's day. `m` per model: [turns, input, cache write, cache read,
63 * output, subagent turns]; `effort` and `attr`: [turns, units]; `dur`: [turns,
64 * ms]; `q`: active quarter-hours.
65 */
66export type DayRow = {
67 m: Record<string, number[]>
68 tools: Record<string, number>
69 q: number[]
70 effort?: Record<string, number[]>
71 attr?: Record<string, number[]>
72 dur?: number[]
73 thinkMs?: number
74 aborted?: number
75 hits?: Hit[]
76 compact?: Compaction[]
77}
78export type FileRollup = { project: string; days: Record<string, DayRow> }
79export type Rollup = { version: 1; files: Record<string, FileRollup> }
80
81export const pad = (n: number) => String(n).padStart(2, '0')
82export const localDay = (t: number) => {
83 const d = new Date(t)
84 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
85}
86
87/** The project a transcript belongs to: the last part of its cwd, else its folder. */
88export function projectOf(text: string, path: string): string {
89 const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(text)
90 if (m) {
91 const cwd = JSON.parse(`"${m[1]}"`) as string
92 const name = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop()
93 if (name) return name
94 }
95 return path.split('/').slice(-2, -1)[0] ?? ''
96}
97
98export function rollupTranscript(tr: Transcript): Record<string, DayRow> {
99 const days: Record<string, DayRow> = {}
100 const day = (t: number) => (days[localDay(t)] ??= { m: {}, tools: {}, q: [] })
101 const add = (rec: Record<string, number[]>, key: string, u: number) => {
102 const v = (rec[key] ??= [0, 0])
103 v[0]! += 1
104 v[1]! += u
105 }
106 for (const t of tr.turns) {
107 const row = day(t.t)
108 const m = (row.m[t.model] ??= [0, 0, 0, 0, 0, 0])
109 m[0]! += 1
110 m[1]! += t.in
111 m[2]! += t.cw
112 m[3]! += t.cr
113 m[4]! += t.out
114 m[5]! += t.sub ? 1 : 0
115 for (const name of t.tools ?? []) row.tools[name] = (row.tools[name] ?? 0) + 1
116 const d = new Date(t.t)
117 const q = d.getHours() * 4 + Math.floor(d.getMinutes() / 15)
118 if (!row.q.includes(q)) row.q.push(q)
119 const u = units(t)
120 if (t.effort) add((row.effort ??= {}), t.effort, u)
121 for (const a of t.attr ?? []) add((row.attr ??= {}), a, u)
122 if (t.thinkMs) row.thinkMs = (row.thinkMs ?? 0) + t.thinkMs
123 if (t.aborted) row.aborted = (row.aborted ?? 0) + 1
124 }
125 for (const x of tr.durations) {
126 const row = day(x.t)
127 row.dur = [(row.dur?.[0] ?? 0) + 1, (row.dur?.[1] ?? 0) + x.ms]
128 }
129 for (const h of tr.hits) (day(h.t).hits ??= []).push(h)
130 for (const c of tr.compactions) (day(c.t).compact ??= []).push(c)
131 for (const row of Object.values(days)) row.q.sort((a, b) => a - b)
132 return days
133}
134
135export function byMonth(days: Record<string, DayRow>): Record<string, Record<string, DayRow>> {
136 const out: Record<string, Record<string, DayRow>> = {}
137 for (const [day, row] of Object.entries(days)) (out[day.slice(0, 7)] ??= {})[day] = row
138 return out
139}
140
141// ── Analyses ────────────────────────────────────────────────────────────────
142
143/** Windows that reached the limit, from readings at 100% and from refused requests in transcripts. */
144export function limitHits(readings: Reading[], hits: Hit[] = []): Hit[] {
145 const fromReadings = readings
146 .filter(x => x.p >= 99.5 && x.r)
147 .map(x => ({ t: x.t, kind: x.kind, r: normReset(x.r!), tries: 0 }))
148 return mergeHits([...hits, ...fromReadings]).sort((a, b) => a.t - b.t)
149}
150
151export const blockedMs = (h: Hit) => Math.max(0, Date.parse(h.r) - h.t)
152
153const turnUnits = (t: LoggedTurn) => (t.m ? units({ model: t.m, in: t.i, cw: t.cw, cr: t.cr, out: t.o }) : 0)
154
155/**
156 * Whether a shown tip was followed within an hour, in the session it was shown
157 * in. Undefined where the tip asks for nothing observable.
158 */
159export function tipFollowed(tip: LoggedEvent, turns: LoggedTurn[], events: LoggedEvent[]): boolean | undefined {
160 const w = 60 * MINUTE
161 const mine = turns.filter(t => t.s === tip.s)
162 const after = mine.filter(t => t.t > tip.t && t.t <= tip.t + w)
163 const before = mine.filter(t => t.t <= tip.t && t.t > tip.t - w)
164 switch (tip.id) {
165 case 'context': {
166 if (events.some(e => e.s === tip.s && e.e === 'compact' && e.t > tip.t && e.t <= tip.t + w)) return true
167 const last = [...before].reverse().find(t => !t.sub && t.x !== undefined)?.x
168 return last !== undefined && after.some(t => !t.sub && t.x !== undefined && t.x < last / 2)
169 }
170 case 'model':
171 return after.some(t => !t.sub && t.m !== undefined && !isOpus(t.m))
172 case 'subagents': {
173 const share = (ts: LoggedTurn[]) => {
174 const all = ts.reduce((a, t) => a + turnUnits(t), 0)
175 return all > 0 ? ts.filter(t => t.sub).reduce((a, t) => a + turnUnits(t), 0) / all : undefined
176 }
177 const b = share(before)
178 const a = share(after)
179 return b !== undefined && a !== undefined ? a < b / 2 : undefined
180 }
181 case 'break':
182 return !mine.some(t => t.t > tip.t && t.t <= tip.t + 30 * MINUTE)
183 default:
184 return undefined
185 }
186}
187
188// ── Export ──────────────────────────────────────────────────────────────────
189
190const cell = (v: unknown) => {
191 const s = v === undefined || v === null ? '' : typeof v === 'number' ? String(Math.round(v * 1e4) / 1e4) : String(v)
192 return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
193}
194export const csv = (header: string[], rows: unknown[][]) => [header, ...rows].map(r => r.map(cell).join(',')).join('\n') + '\n'
195const iso = (t: number) => new Date(t).toISOString()
196const median = (xs: number[]) => (xs.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : undefined)
197
198export type ExportInput = { log: Log; rollups: Rollup[]; now: number }
199
200/** The export folder's files, by name: CSV tables plus a summary for presentations. */
201export function buildExport(x: ExportInput): Record<string, string> {
202 const files: Record<string, FileRollup> = {}
203 for (const r of x.rollups) {
204 for (const [path, f] of Object.entries(r.files)) {
205 const cur = (files[path] ??= { project: f.project, days: {} })
206 Object.assign(cur.days, f.days)
207 }
208 }
209
210 const usage: unknown[][] = []
211 const tools: unknown[][] = []
212 const active = new Map<string, Set<number>>()
213 const time = new Map<string, { turnMs: number; thinkMs: number; aborted: number }>()
214 const efforts: unknown[][] = []
215 const attrs: unknown[][] = []
216 const compactRows: unknown[][] = []
217 const transcriptHits: Hit[] = []
218 const byEffort: Record<string, { turns: number; usd: number }> = {}
219 const byAttr: Record<string, { turns: number; usd: number }> = {}
220 const sum = (rec: Record<string, { turns: number; usd: number }>, key: string, n: number, usd: number) => {
221 const v = (rec[key] ??= { turns: 0, usd: 0 })
222 v.turns += n
223 v.usd += usd
224 }
225 const byModel: Record<string, { turns: number; usd: number }> = {}
226 const byProject: Record<string, { turns: number; usd: number }> = {}
227 let turnsTotal = 0
228 let usdTotal = 0
229 const tok = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }
230 for (const f of Object.values(files)) {
231 for (const [day, row] of Object.entries(f.days)) {
232 for (const [model, [n = 0, i = 0, cw = 0, cr = 0, o = 0, sub = 0]] of Object.entries(row.m)) {
233 const usd = units({ model, in: i, cw, cr, out: o })
234 usage.push([day, f.project, model, n, i, cw, cr, o, sub, usd])
235 turnsTotal += n
236 usdTotal += usd
237 tok.input += i
238 tok.cacheWrite += cw
239 tok.cacheRead += cr
240 tok.output += o
241 const bm = (byModel[model] ??= { turns: 0, usd: 0 })
242 bm.turns += n
243 bm.usd += usd
244 const bp = (byProject[f.project] ??= { turns: 0, usd: 0 })
245 bp.turns += n
246 bp.usd += usd
247 }
248 for (const [tool, n] of Object.entries(row.tools)) tools.push([day, f.project, tool, n])
249 for (const [effort, [n = 0, usd = 0]] of Object.entries(row.effort ?? {})) {
250 efforts.push([day, f.project, effort, n, usd])
251 sum(byEffort, effort, n, usd)
252 }
253 for (const [key, [n = 0, usd = 0]] of Object.entries(row.attr ?? {})) {
254 const [kind = '', ...name] = key.split(':')
255 attrs.push([day, f.project, kind, name.join(':'), n, usd])
256 sum(byAttr, key, n, usd)
257 }
258 const tm = time.get(day) ?? { turnMs: 0, thinkMs: 0, aborted: 0 }
259 tm.turnMs += row.dur?.[1] ?? 0
260 tm.thinkMs += row.thinkMs ?? 0
261 tm.aborted += row.aborted ?? 0
262 time.set(day, tm)
263 for (const c of row.compact ?? []) compactRows.push([iso(c.t), f.project, c.trigger, c.pre, c.post])
264 transcriptHits.push(...(row.hits ?? []))
265 const set = active.get(day) ?? new Set<number>()
266 for (const q of row.q) set.add(q)
267 active.set(day, set)
268 }
269 }
270 usage.sort((a, b) => String(a[0]).localeCompare(String(b[0])))
271 for (const rows of [tools, efforts, attrs, compactRows]) rows.sort((a, b) => String(a[0]).localeCompare(String(b[0])))
272 const activeRows = [...active].sort(([a], [b]) => a.localeCompare(b)).map(([day, s]) => {
273 const tm = time.get(day)
274 return [day, s.size / 4, (tm?.turnMs ?? 0) / 3_600_000, (tm?.thinkMs ?? 0) / 3_600_000, tm?.aborted ?? 0]
275 })
276
277 const log = x.log
278 const readings = [...log.readings].sort((a, b) => a.t - b.t)
279 const hits = limitHits(readings, transcriptHits)
280 const final = new Map<string, number>()
281 for (const r of readings) final.set(`${r.kind}|${r.r}`, Math.max(final.get(`${r.kind}|${r.r}`) ?? 0, r.p))
282 const quality: Record<string, Quality> = {}
283 for (const kind of ['five_hour', 'seven_day']) quality[kind] = evaluate(log.forecasts, readings, kind, x.now, 0)
284
285 const tips: Record<string, { shown: number; followed: number; measurable: number }> = {}
286 const tipRows: unknown[][] = []
287 for (const e of log.events.filter(e => e.e === 'tip')) {
288 const followed = tipFollowed(e, log.turns, log.events)
289 const s = (tips[String(e.id)] ??= { shown: 0, followed: 0, measurable: 0 })
290 s.shown += 1
291 if (followed !== undefined) s.measurable += 1
292 if (followed) s.followed += 1
293 tipRows.push([iso(e.t), e.s, e.id, followed === undefined ? '' : followed ? 'yes' : 'no'])
294 }
295 const count = (name: string) => log.events.filter(e => e.e === name).length
296 const durations = log.turns.filter(t => !t.sub && t.d !== undefined).map(t => t.d!)
297
298 const round = (v: number) => Math.round(v * 100) / 100
299 const summary = {
300 generatedAt: iso(x.now),
301 note: 'usdEquivalent is the usage priced at API list prices; it is not what a subscription costs.',
302 days: activeRows.length,
303 from: activeRows[0]?.[0],
304 to: activeRows[activeRows.length - 1]?.[0],
305 activeHours: activeRows.reduce((a, r) => a + Number(r[1]), 0),
306 turns: turnsTotal,
307 tokens: tok,
308 cacheHitRate: tok.cacheRead / Math.max(1, tok.input + tok.cacheWrite + tok.cacheRead),
309 usdEquivalent: round(usdTotal),
310 byModel: Object.fromEntries(Object.entries(byModel).map(([k, v]) => [k, { turns: v.turns, usd: round(v.usd) }])),
311 byProject: Object.fromEntries(Object.entries(byProject).sort((a, b) => b[1].usd - a[1].usd).map(([k, v]) => [k, { turns: v.turns, usd: round(v.usd) }])),
312 limitHits: Object.fromEntries(['five_hour', 'seven_day'].map(kind => {
313 const hs = hits.filter(h => h.kind === kind)
314 return [kind, { count: hs.length, blockedHours: round(hs.reduce((a, h) => a + blockedMs(h), 0) / 3_600_000), retries: hs.reduce((a, h) => a + Math.max(0, h.tries - 1), 0) }]
315 })),
316 turnsLogged: {
317 main: log.turns.filter(t => !t.sub).length,
318 aborted: log.turns.filter(t => t.a).length,
319 medianDurationSec: durations.length ? round(durations.sort((a, b) => a - b)[Math.floor(durations.length / 2)]! / 1000) : undefined,
320 },
321 compactions: {
322 manual: compactRows.filter(r => r[2] === 'manual').length,
323 auto: compactRows.filter(r => r[2] === 'auto').length,
324 medianTokensBefore: median(compactRows.map(r => Number(r[3]))),
325 medianTokensAfter: median(compactRows.map(r => Number(r[4]))),
326 },
327 time: {
328 turnHours: round(activeRows.reduce((a, r) => a + Number(r[2]), 0)),
329 thinkingHours: round(activeRows.reduce((a, r) => a + Number(r[3]), 0)),
330 interruptedAnswers: activeRows.reduce((a, r) => a + Number(r[4]), 0),
331 },
332 byEffort: Object.fromEntries(Object.entries(byEffort).map(([k, v]) => [k, { turns: v.turns, usd: round(v.usd) }])),
333 byAttribution: Object.fromEntries(
334 Object.entries(byAttr).sort((a, b) => b[1].usd - a[1].usd).slice(0, 20).map(([k, v]) => [k, { turns: v.turns, usd: round(v.usd) }]),
335 ),
336 warnings: { shown: count('warn'), hidden: count('hide'), paneOpened: count('pane') },
337 tips,
338 forecastQuality: quality,
339 }
340
341 return {
342 'usage-daily.csv': csv(['date', 'project', 'model', 'turns', 'input', 'cache_write', 'cache_read', 'output', 'subagent_turns', 'usd_equivalent'], usage),
343 'tools-daily.csv': csv(['date', 'project', 'tool', 'calls'], tools),
344 'active-daily.csv': csv(['date', 'active_hours', 'turn_hours', 'thinking_hours', 'interrupted_answers'], activeRows),
345 'effort-daily.csv': csv(['date', 'project', 'effort', 'turns', 'usd_equivalent'], efforts),
346 'attribution-daily.csv': csv(['date', 'project', 'kind', 'name', 'turns', 'usd_equivalent'], attrs),
347 'compactions.csv': csv(['time', 'project', 'trigger', 'tokens_before', 'tokens_after'], compactRows),
348 'limits.csv': csv(['time', 'window', 'percent', 'resets_at'], readings.map(r => [iso(r.t), LABEL[r.kind] ?? r.kind, r.p, r.r])),
349 'limit-hits.csv': csv(['time', 'window', 'resets_at', 'blocked_hours', 'retries'], hits.map(h => [iso(h.t), LABEL[h.kind] ?? h.kind, h.r, blockedMs(h) / 3_600_000, Math.max(0, h.tries - 1)])),
350 'forecasts.csv': csv(
351 ['time', 'window', 'percent', 'forecast', 'lo80', 'hi80', 'risk', 'samples', 'baseline', 'resets_at', 'final'],
352 [...log.forecasts].sort((a, b) => a.t - b.t).map(f => [iso(f.t), LABEL[f.kind] ?? f.kind, f.p, f.pt, f.lo, f.hi, f.risk, f.n, f.b, f.r, Date.parse(f.r) <= x.now ? final.get(`${f.kind}|${f.r}`) : undefined]),
353 ),
354 'turns.csv': csv(
355 ['time', 'session', 'model', 'input', 'cache_write', 'cache_read', 'output', 'subagent', 'context', 'duration_ms', 'aborted'],
356 [...log.turns].sort((a, b) => a.t - b.t).map(t => [iso(t.t), t.s, t.m, t.i, t.cw, t.cr, t.o, t.sub ? 1 : 0, t.x, t.d, t.a ? 1 : 0]),
357 ),
358 'events.csv': csv(
359 ['time', 'session', 'event', 'detail'],
360 [...log.events].sort((a, b) => a.t - b.t).map(e => {
361 const { t, s, e: name, ...rest } = e
362 return [iso(t), s, name, Object.keys(rest).length ? JSON.stringify(rest) : '']
363 }),
364 ),
365 'tips.csv': csv(['time', 'session', 'tip', 'followed'], tipRows),
366 'summary.json': JSON.stringify(summary, null, 2) + '\n',
367 }
368}
369hooks/stats.ts 134 lines1// Small statistics helpers, pure.
2
3export const mean = (xs: number[]) => (xs.length ? xs.reduce((a, x) => a + x, 0) / xs.length : NaN)
4
5/** Sample quantile, linear interpolation between order statistics (R type 7). */
6export function quantile(xs: number[], q: number): number {
7 const s = [...xs].sort((a, b) => a - b)
8 if (s.length === 0) return NaN
9 const h = (s.length - 1) * q
10 const lo = Math.floor(h)
11 return s[lo]! + (h - lo) * ((s[Math.min(lo + 1, s.length - 1)] ?? s[lo]!) - s[lo]!)
12}
13
14/** Coefficient of variation (sample sd / mean). */
15export function cv(xs: number[]): number | undefined {
16 if (xs.length < 2) return undefined
17 const m = mean(xs)
18 if (!(m > 0)) return undefined
19 const v = xs.reduce((a, x) => a + (x - m) ** 2, 0) / (xs.length - 1)
20 return Math.sqrt(v) / m
21}
22
23/** Ordinary least-squares slope of y on x. */
24export function slope(xs: number[], ys: number[]): number | undefined {
25 if (xs.length < 2) return undefined
26 const mx = mean(xs)
27 const my = mean(ys)
28 let sxy = 0
29 let sxx = 0
30 for (let i = 0; i < xs.length; i++) {
31 sxy += (xs[i]! - mx) * (ys[i]! - my)
32 sxx += (xs[i]! - mx) ** 2
33 }
34 return sxx > 0 ? sxy / sxx : undefined
35}
36
37export type Pair = { y: number; x: number; w: number }
38
39/**
40 * Ratio estimator k = Σw·y / Σw·x: weighted least squares through the origin
41 * when Var(y) ∝ x. Standard error from the residuals; undefined below 3 pairs.
42 */
43export function ratioFit(pairs: Pair[]): { k: number; se?: number } | undefined {
44 const sy = pairs.reduce((a, p) => a + p.w * p.y, 0)
45 const sx = pairs.reduce((a, p) => a + p.w * p.x, 0)
46 if (!(sx > 0)) return undefined
47 const k = sy / sx
48 const n = pairs.length
49 if (n < 3) return { k }
50 const sw = pairs.reduce((a, p) => a + p.w, 0)
51 const s2 = (pairs.reduce((a, p) => a + (p.w * (p.y - k * p.x) ** 2) / p.x, 0) / sw) * (n / (n - 1))
52 const varK = (s2 * pairs.reduce((a, p) => a + p.w * p.w * p.x, 0)) / (sx * sx)
53 return { k, se: Math.sqrt(varK) }
54}
55
56/**
57 * y = a·x1 + b·x2 without intercept, weighted least squares (weights w / (x1+x2),
58 * i.e. Var(y) ∝ x). Returns a/b with a delta-method standard error.
59 */
60export function ratioOfTwo(rows: { y: number; x1: number; x2: number; w: number }[]): { ratio: number; se: number } | undefined {
61 const rs = rows.filter(r => r.x1 + r.x2 > 0)
62 if (rs.length < 6) return undefined
63 let s11 = 0, s12 = 0, s22 = 0, s1y = 0, s2y = 0
64 for (const r of rs) {
65 const w = r.w / (r.x1 + r.x2)
66 s11 += w * r.x1 * r.x1
67 s12 += w * r.x1 * r.x2
68 s22 += w * r.x2 * r.x2
69 s1y += w * r.x1 * r.y
70 s2y += w * r.x2 * r.y
71 }
72 const det = s11 * s22 - s12 * s12
73 // Needs both kinds of usage to vary independently, or the split is unknowable.
74 if (!(det > 1e-9 * s11 * s22)) return undefined
75 const a = (s22 * s1y - s12 * s2y) / det
76 const b = (s11 * s2y - s12 * s1y) / det
77 if (!(a > 0) || !(b > 0)) return undefined
78 let rss = 0
79 for (const r of rs) rss += (r.w / (r.x1 + r.x2)) * (r.y - a * r.x1 - b * r.x2) ** 2
80 const s2 = rss / (rs.length - 2)
81 const vA = (s2 * s22) / det
82 const vB = (s2 * s11) / det
83 const cAB = (-s2 * s12) / det
84 const ratio = a / b
85 const v = vA / (b * b) + (a * a * vB) / b ** 4 - (2 * a * cAB) / b ** 3
86 return { ratio, se: Math.sqrt(Math.max(0, v)) }
87}
88
89/** Standard normal CDF (Abramowitz & Stegun 7.1.26, error below 1.5e-7). */
90export function normCdf(z: number): number {
91 const x = Math.abs(z) / Math.SQRT2
92 const t = 1 / (1 + 0.3275911 * x)
93 const erf = 1 - t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429)))) * Math.exp(-x * x)
94 return z >= 0 ? (1 + erf) / 2 : (1 - erf) / 2
95}
96
97/** Silverman's rule-of-thumb bandwidth for a Gaussian kernel. */
98export function bandwidth(xs: number[]): number {
99 if (xs.length < 2) return 0
100 const m = mean(xs)
101 const sd = Math.sqrt(xs.reduce((a, x) => a + (x - m) ** 2, 0) / (xs.length - 1))
102 const iqr = (quantile(xs, 0.75) - quantile(xs, 0.25)) / 1.34
103 return 0.9 * (iqr > 0 ? Math.min(sd, iqr) : sd) * Math.pow(xs.length, -0.2)
104}
105
106/**
107 * P(X ≥ x) from a Gaussian kernel density estimate of the samples, each kernel
108 * widened by `extraSd` (another, independent error source). With no spread at
109 * all it is the plain share of samples at or above x.
110 */
111export function kdeTail(xs: number[], x: number, extraSd = 0): number {
112 const s = Math.hypot(bandwidth(xs), extraSd)
113 if (!(s > 0)) return xs.filter(v => v >= x).length / xs.length
114 return mean(xs.map(v => 1 - normCdf((x - v) / s)))
115}
116
117/**
118 * The q-quantile of the same kernel density estimate as `kdeTail`, found by
119 * bisection, so a range and a risk from it always agree. With no spread at
120 * all it is the plain sample quantile.
121 */
122export function kdeQuantile(xs: number[], q: number, extraSd = 0): number {
123 const s = Math.hypot(bandwidth(xs), extraSd)
124 if (!(s > 0)) return quantile(xs, q)
125 let lo = Math.min(...xs) - 6 * s
126 let hi = Math.max(...xs) + 6 * s
127 for (let i = 0; i < 60; i++) {
128 const mid = (lo + hi) / 2
129 if (mean(xs.map(v => normCdf((mid - v) / s))) < q) lo = mid
130 else hi = mid
131 }
132 return (lo + hi) / 2
133}
134types/index.d.ts 107 lines1export type Verdict = 'ok' | 'slow' | 'hold'
2
3export type ForecastView = {
4 kind: string
5 label: string
6 p: number
7 msToReset?: number
8 rate?: number
9 rateBasis: string
10 msToLimit?: number
11 /** Point forecast of the percent at reset. */
12 projected?: number
13 /** 80% prediction interval of the percent at reset. */
14 lo?: number
15 hi?: number
16 /** Probability of reaching the limit before reset. */
17 risk?: number
18 samples?: number
19 sampleUnit?: 'days' | 'weeks'
20 baseline?: number
21 pace?: number
22 perDayLeft?: number
23 verdict: Verdict
24 headline: string
25}
26
27export type CalibView = {
28 kind: string
29 label: string
30 k?: number
31 se?: number
32 n: number
33 points: number
34 /** `se` is an assumed ±25% until 3 stretches give a measured one. */
35 seAssumed?: boolean
36 /** Re-learned from recent stretches only, after a change from this time on. */
37 changedAt?: number
38}
39
40/** Forecast settings learned by replaying past weeks. */
41export type TuningView = {
42 kind: string
43 label: string
44 tau: number
45 halfLife: number
46 scale: number
47 bias: number
48 n: number
49 independent: number
50 gain?: number
51 coverage0?: number
52 coverage?: number
53}
54
55export type QualityView = {
56 kind: string
57 label: string
58 n: number
59 mae?: number
60 bias?: number
61 coverage?: number
62 nInterval: number
63 width?: number
64 brier?: number
65 nRisk: number
66 skill?: number
67}
68
69export type View = {
70 updatedAt: number
71 /** When Claude Code last reported the limits (they come with responses). */
72 reportedAt?: number
73 overall: Verdict
74 /** Identifies the current warning, so "Hide" lasts until it changes. */
75 warningKey: string
76 forecasts: ForecastView[]
77 tips: { id: string; text: string }[]
78 learned: {
79 calib: CalibView[]
80 /** Observed Opus cost relative to the assumed weight (1 = as assumed). */
81 opusCheck?: { ratio: number; se: number; n: number; applied: boolean }
82 /** Forecast settings learned by replaying past weeks, per window. */
83 tuning: TuningView[]
84 /** Week-to-week variation of usage (coefficient of variation). */
85 regularity?: { cv: number; weeks: number }
86 }
87 quality: QualityView[]
88 history: {
89 scanning: boolean
90 days: number
91 files: number
92 skipped: number
93 busiest: string[]
94 /** Estimated weekly percent of the past weeks, most recent first. */
95 pastWeeks: number[]
96 /** Times a limit ran out, from transcripts and live readings. */
97 hits: { count: number; fiveHour: number; weekly: number; blockedHours: number; last?: number }
98 }
99 folder: string
100}
101
102declare module 'claude-code' {
103 interface PluginState {
104 'limits-forecast': { view: View | null; hiddenKey: string }
105 }
106}
107