SLOPSHOPPER

limits-forecast

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.

newpanebandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits-forecast
│ ┃ limits ✕ › fix the failing auth test and add an audit log call │ ┃ Collecting data… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /limits-forecast │ ⎿ limits-forecast: Usage limits: collecting data… │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · limits
Collecting data…
README

limits-forecast

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.

Claude Code mod TypeScript


What you get

Limits line above the prompt

5h ███▓▓▒··│·· 42% ↻ 2h30 → 68% (61–77) risk 0% ● OK   │   wk █████▓▓▓│▒▒ 61% ↻ 3d → 104% (88–119) risk 75% ● SLOW DOWN   · 14:02
FieldMeaning
· 14:02When 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 / wkThe 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.
↻ 2h30Time 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 ONThe 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:

  • Verdict: OK green, SLOW DOWN yellow, HOLD ON red.
  • Forecast: green, yellow when it is at 90% or more or its range reaches past 100%, red when it is over 100%. So a tight window stands out even while the verdict is OK.
  • Bar: the used part in the verdict color, the rest dim.

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 pane

5-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
  • The bar runs from 0 to 125% in 5% cells. █ 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.
  • Suggestions (see below).
  • Learning: how many tokens make one percent and its standard error (or the assumed one, and whether it re-learned after a change), the Opus weight check and whether it's applied, what the replay tuned per window, and how regular your weeks are. Each row says what data it is still waiting for.
  • Forecast quality: the scores in plain words.
  • History: transcripts read, your past limit hits and hours blocked, your busiest days, and your past weeks in percent.

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 export

This writes 13 CSV tables and a summary.json for retrospectives. The files are listed under Your data.

Toasts

You get a toast when the verdict gets worse, and when a window crosses 80% and 90%.


Install

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.


Model calls and network

QuestionAnswer
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.

How it works

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
  1. Readings. After each response Claude Code reports every limit window (five_hour, seven_day) with the percent used and the reset time. The mod logs each move of a window.
  2. Usage. Every 5 minutes (and 1 second after start) the mod reads all sessions' transcripts in ~/.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.
  3. Past hits. When you ran out, Claude Code wrote the refused request into the transcript with 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.
  4. Calibration learns how many percent one unit costs, separately for each window.
  5. Profile learns when you usually work: weekday totals times an hour-of-day shape.
  6. Forecast combines your usual pattern, today's deviation from it and the spread of your past weeks.
  7. Self-scoring compares every logged forecast with the final percent once its window resets.
  8. Self-tuning replays past weeks to learn the settings the forecast uses: how fast bursts fade, how much recent weeks count, any bias, and how wide the range must be to hold 80%.

The math

1. Units: tokens → dollars

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)

2. Calibration: percent per unit (ratio estimator)

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$:

  • $y_i$ = percent points moved
  • $x_i$ = units used by all local sessions in that stretch
  • $w_i = 2^{-\text{age}_i / 14\,\text{d}}$: exponential recency weighting, half-life 14 days, looking back 28 days

The 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:

  • $\hat k$ is used once the stretches add up to ≥ 3 percent points.
  • The SE needs ≥ 3 stretches. Until then an SE of 25% of $\hat k$ is assumed, so that the range doesn't treat a conversion learned from a single limit hit as exact. The pane marks it as assumed.
  • Stretches with points moved but no local usage are dropped. That usage came from claude.ai or another machine.

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)

3. Opus weight check (two-variable WLS + delta method)

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$:

  • 1.0 means the assumed weight is right.
  • 1.3 means Opus costs 30% more of the limit than assumed.

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)

4. Weekly profile (multiplicative weekday × hour model)

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
  • $\bar U_d$ is the recency-weighted mean daily total for weekday $d$ (same 14-day half-life), over the last 8 weeks, complete days only, idle days counted as zero. A weekday with no history falls back to the overall mean.
  • $s_h$ is the hour-of-day shape. It is recency-weighted, smoothed with a $[\tfrac14,\tfrac12,\tfrac14]$ kernel over neighbouring hours, and normalized so that $\sum_h s_h = 1$.

(profile, expectedUnits)

5. Point forecast (profile + mean-reverting deviation)

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.

  • Usual usage until reset: $U_{\text{usual}} = \int_{\text{now}}^{\text{reset}} \text{expected}(t)\,dt$
  • Today's deviation: $\Delta = \dfrac{U_{\text{last }L}}{L} - \dfrac{U^{\text{expected}}_{\text{last }L}}{L}$ (units per hour)

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)

6. Baseline: "the current speed holds"

\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:

  • It's the naive benchmark the skill score compares against.
  • It feeds the "you hit the limit in ~40 m" warning.

(readingRate, slope)

7. 80% prediction interval and risk (kernel density estimate)

How much could the rest of this window differ from the forecast? The mod looks at how the same stretch went in the past:

  • Weekly window: the same stretch (now → reset) in each of up to 9 previous weeks.
  • 5-hour window: the same clock stretch on up to 14 previous days of the same kind (workday or weekend), looking back 28 days.

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.

8. Regularity

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)

9. Self-tuning by replaying the past

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):

  1. Replay. At past moments (every 2 h for the 5-hour window, every 6 h for the week) it forecasts the usage until the current horizon, using only what was known at that moment, and compares with what happened. This is done in usage units, so no past limit readings are needed.
  2. Point forecast. It tries $\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.
  3. Bias. With those settings, forecasts are multiplied by $\sum \text{actual} / \sum \text{forecast}$ (a ratio estimator again), clipped to $[0.67, 1.5]$, and ignored when within 5% of 1.
  4. Range width. The deviations of the past stretches are scaled by a factor $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:

  • For the 5-hour window that's after a few days.
  • For the week it takes about 6–7 weeks of history: 3 weeks to compare with, plus the replay span.

The pane's "fit" rows show what was learned, for example "range ×2.5 so it held 80% instead of 59%". (tune, expectedBase, scenarios)

Verdicts

VerdictWhen
HOLD ON≥ 95% used, or the current speed reaches 100% before the reset and within 45 min (5-hour) / 1 day (week).
SLOW DOWNrisk > 50% (or, before risk exists, point forecast > 100%), or ≥ 85% used.
OKOtherwise.

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.

Suggestions

Shown when a window is tight: not OK, or a forecast ≥ 100%, or a risk ≥ 50%. Each one is triggered by a measurement:

TipTrigger
/compact or /clearmain context > 120k tokens (every message re-sends it)
Switch to SonnetOpus > 60% of the last 5 h of usage (Sonnet makes the same limit last ≈ 5/3 ≈ 1.7× longer)
Fewer subagentssubagents > 40% of the last 5 h
Take a breakonly the 5-hour window is in trouble: a break until its reset costs nothing from the week
Daily budgetthe weekly window is in trouble: $(100 - p) / \text{days left}$ per day
Room for big tasksnothing 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.


How it scores itself

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.

MetricFormulaGood 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% intervalshare 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:

  • Forecasts and outcomes are capped at 100% before scoring, since the reported percent can overshoot.
  • The ±0.5 tolerance on coverage allows for windows moving in whole points.

The pane shows these in plain words, and forecasts.csv has every scored forecast for your own analysis.


Your data

Everything stays in ~/.claude/limit-metrics/ (or $CLAUDE_CONFIG_DIR/limit-metrics/).

FileContents
log-YYYY-MM-<session>.jsonlReadings, 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.jsonEach 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.jsonUsage 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.

Export (/limits-forecast export → export/)

FileContents
usage-daily.csvdate, project, model, tokens by type, API-equivalent $
tools-daily.csvtool calls per day and project
active-daily.csvactive hours, turn hours, thinking hours, interrupted answers
effort-daily.csvusage per effort level
attribution-daily.csvusage per skill, plugin, MCP server and subagent type
compactions.csvmanual or auto, tokens before and after
limits.csvevery reading
limit-hits.csvevery limit hit, including from before the mod was installed, with hours blocked and retries
forecasts.csvevery forecast with its outcome
turns.csvevery turn logged live
events.csvwarnings, hides, pane opens
tips.csveach tip shown and whether it was followed
summary.jsontotals, 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.


Limitations

  • Plans: the limit readings exist on subscription plans only, and the first one arrives with the first response of a session.
  • Conversion: Anthropic doesn't publish the usage → percent formula. The conversion is learned and approximate, and windows move in whole points, so the first stretches are coarse.
  • Other usage: limit hits and stretches assume the usage came from Claude Code on this machine. claude.ai or another computer in the same window makes the limit look smaller. Stretches with no local usage are dropped.
  • History needed: the interval and risk need a few past weeks (days for the 5-hour window).
  • File size:
  • Transcripts over 4 MiB are streamed through cat/type. The pane shows how many couldn't be read.
  • A monthly rollup over 4 MiB stops updating.
  • Timezone: the weekday profile uses the mod runtime's timezone.
  • Old logs: they aren't deleted automatically, because mods can't delete files. They're small, so delete old months by hand if you like.

Development

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, r
Source 5 files
hooks/register.tsx 847 lines
1import { 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}
847
hooks/model.ts 1026 lines
1// 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}
1026
hooks/retro.ts 369 lines
1// 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}
369
hooks/stats.ts 134 lines
1// 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}
134
types/index.d.ts 107 lines
1export 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