SLOPSHOPPER

burnrate

See whether you can go hard or should hold back: live 5-hour and 7-day quota pace, projection at reset, daily budget, quota size in API dollars, cost per…

panebandcommandtoasttimer
v0.5.0MITupdated 2026-10-19axelhamil/burnrate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · burnrate
│ ┃ burnrate ✕ › fix the failing auth test and add an audit log call │ ┃ No quota data yet │ ┃ Needs a subscription and one API response. ⏺ 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 │ │ › /burnrate │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · burnrate
No quota data yet Needs a subscription and one API response.
README

burnrate

A Claude Code pane that answers one question: can I go hard right now, or should I hold back? It tracks your subscription quota for the 5-hour and 7-day windows, projects where you will land at reset and tells you how much you can spend per day or per hour. It also shows what each project costs you, warns you before a limit and recaps last week.

7-day window · Ahead of pace: ease off                  5-hour window · Room to spare
███████████████│█████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 44%    ██████████████████████░░░░░│░░░░░░░░░░░░░░░░░░░░ 45%
Used 44% · resets Sun 11:00 (in 4d 20h)                 Used 45% · resets 17:10 (in 2h 10m)
Projected at reset 77% from your last 4 weeks           Projected at reset 79% linear, no history yet
Budget 12%/day to land at 100%                          Budget 25%/hour to land at 100%
Today 14% of 14% ██████████                             Quota size: learning (needs 5 points)
Sun 11 ██████████░░░░░░░░░░░░░░ 9%
Mon 12 ████████████████████████ 21%
Tue 13 ████████████████░░░░░░░░ 14% today
Quota ≈ $260 / week (1% ≈ $2.60)
This session $6.00 (2.3%)
Top projects this week
api $20 (7.7%)
burnrate $6.00 (2.3%)
Last week peaked 50%

│ marks where an even pace would be now. $ are API-equivalent and count this machine only.

More states and widths (60, 90 and 140 columns, band-* for the band) live in docs/snapshots.

Install

In Claude Code:

/plugin install burnrate --marketplace axelhamil/burnrate

Answer y to add the axelhamil/burnrate marketplace, then pick a scope (user, project or local).

From a shell:

claude plugin install burnrate --marketplace axelhamil/burnrate

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with axelhamil/burnrate, then Add plugin.

Usage

/burnrate toggles the pane. It refreshes on every API response and once a minute.

A one-line band also shows right above the prompt, behind a bar tinted by the verdict, for example ▌ ● easy tiger 7d → 77% 12%/d today █████ 14/14% session 2.3% ▄█▆ 5h 45%. It opens with the worse verdict of the two windows in a word (go wild, cruising, easy tiger, hit the brakes, tapped out), then the 7d projection, the daily budget, today against its budget (with a bar from 82 columns), what this session cost in quota points (in dollars until the quota size is learned), one bar per day with a reading, shown from 2 days (yellow above the even pace) and the 5h use. When the line is too long for the terminal it drops the day bars first, then the session, today and the budget. Turn it off with the statusLine option (default on) in the plugin settings.

On Opus, when the worse verdict is easy tiger or above, the band ends with try sonnet.

Every figure in the band is clickable: a click toggles the pane, and a click on try sonnet puts /model sonnet in an empty prompt, ready to send.

The pane also lists this session's cost and the top 5 projects of the current 7d window (a project is the folder the session started in), with their share of the quota once its size is known, and a Last week line with the peak of the 7d window that closed in the last 7 days and its busiest day.

Alerts

With the alerts option (default on):

  • a toast the first time a window reaches a worse verdict, from easy tiger up (burnrate · 5h hit the brakes), shared across sessions;
  • a system notification when a window is projected to hit its limit within 30 minutes, or has hit it, once per window;
  • a toast recapping last week (burnrate · last week peaked 62%, busiest Sat 10 at 40%), once per closed week.

Each window gets a verdict:

VerdictBandWhen
Limit reachedtapped outused is 100%
Will hit the limit around ...hit the brakesprojection above 100%
Ahead of pace: ease offeasy tigerbudget below 85% of the even pace
Room to sparego wildbudget at least 115% of the even pace and projection at most 85%
On trackcruisinganything else

How it is computed

Notation: p is the percentage used, never below the peak any session recorded for the window (an idle session's reading goes stale), e the elapsed fraction of the window (0 to 1). The last 6 closed windows of the same kind are weighted 0.5^(age / 2), age 0 being the most recent.

  • T, the typical peak, is the weighted mean of their peaks.
  • For each past window with hourly samples, its use at the same offset as now is interpolated between the last sample at or before that offset and the first one after it, anchored at 0 at the window start and at its peak at reset. R is the weighted mean of max(0, peak - use at that offset).

Projection at reset, first that applies:

  • analog, when at least 2 past windows have samples: p + R (label from your last N weeks)
  • typical, with peaks only: p + (1 - e) * (p + (1 - e) * T) (label typical week)
  • linear, without history: p / e, shown once at least 10% of the window has elapsed

Budget, per day for 7d and per hour for 5h: (100 - p) / units left before reset. The even pace is 100 / 7 per day or 100 / 5 per hour; the budget tone compares the two.

Today (7d only): used today = p - p at day start, against min(headroom, headroom / days left from day start) where headroom = 100 - p at day start. The day start is the window's peak before the first reading of the day, or that reading when the window has none or was last sampled more than a day ago.

Hit time, when the projection passes 100%: rate = (projection - p) / seconds left, hit at = now + (100 - p) / rate.

Quota size: Claude Code reports a session cost in API-equivalent dollars. burnrate records it per hour and compares it with the hourly samples of each window, 7d and 5h alike. For a window, $ per 1% = sum of $ spent in hours where the percentage rose / points gained, once at least 5 points were gained with some spend recorded. Quota ≈ 100 * $ per 1% per week or per 5 hours. While the current window has under 5 points, the size falls back to the latest closed window that has a ratio. Windows that started before the 49-day cost retention are left out.

Quota change (7d only): the current and previous windows are compared with the median $ per 1% of the closed windows before them. When both differ by more than 20% in the same direction, the pane shows Quota up ~N% vs usual or Quota down ~N% vs usual.

Data

  • The plugin store ($.store, key history): peaks per window, the percentage at each day start and hourly samples. Peaks and hourly samples are kept 49 days (enough for 6 past weeks), day starts 30 days.
  • The plugin store, key lastReadings: the last readings recorded, so an unchanged reading polled by the minute ticker is not recorded again.
  • The plugin store, keys alerts (worst verdict reached and notification per window, until the window resets) and reportedWeek (the last week recapped).
  • ~/.claude/burnrate/cost/<sessionId>.json (or under $CLAUDE_CONFIG_DIR): one file per session with its hourly cost and project folder name, so concurrent sessions never write the same file. Buckets older than 49 days are dropped; files untouched for 49 days are ignored but stay on disk, since the plugin API cannot delete files.

Nothing leaves your machine.

Limitations

  • Dollars come from the sessions on this machine only. Usage from another computer, the web or the mobile app raises the percentage without any matching cost, so the $ per 1% and the quota size read low when you use several devices.
  • The percentage arrives with one decimal and moves in small steps, so quota size needs at least 5 points of gain before it shows, and short windows are noisy.
  • Projections need a few closed windows to stop being linear guesses.
  • Session and project costs count the hours that overlap the 7d window, so a session started in the hour before it opened is counted in full for that hour.

Development

claude --plugin-dir .
claude plugin validate .
claude plugin test .

scripts/snapshots.sh regenerates docs/snapshots/*.txt and *.ansi from the test harness. tsc -p . type-checks once Claude Code has laid .claude-plugin/types/.

License

MIT

Source 12 files
hooks/register.tsx 271 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { BurnrateCostLedger, BurnrateHistory, BurnrateReading, BurnrateSessionCost, BurnrateSnapshot } from '../types'
4import { alertsFor, parseAlertState, withNotified, type Notice } from './alerts'
5import { bandTree } from './band'
6import { insightsOf, type WindowInsight } from './insight'
7import { EMPTY_HISTORY, localDate, parseHistory, recordReadings, RETENTION_MS, withPeaks } from './pace'
8import { paneTree, type Elements } from './pane'
9import { mergeCosts, parseLedger, recordCost } from './quota-size'
10import { reportText } from './report'
11
12const PANE = 'burnrate'
13const STORE_KEY = 'history'
14const LAST_READINGS_KEY = 'lastReadings'
15const ALERTS_KEY = 'alerts'
16const REPORTED_WEEK_KEY = 'reportedWeek'
17const UNLABELED = 'other'
18const ALERT_TOAST_MS = 8_000
19const SONNET_COMMAND = '/model sonnet'
20const TICK_MS = 60_000
21const EMPTY_SNAPSHOT: BurnrateSnapshot = {
22  readings: [],
23  history: EMPTY_HISTORY,
24  costs: {},
25  sessions: [],
26  sessionId: '',
27  model: '',
28  now: 0,
29}
30
31type Settings = { isAlertsOn: boolean }
32
33const snapshot = { plugin: 'burnrate', key: 'snapshot' } as const
34
35let ticker: { cancel: () => void } | undefined
36let pending: Promise<void> = Promise.resolve()
37
38function toReadings(rateLimits: readonly BurnrateReading[]): BurnrateReading[] {
39  return rateLimits.map(({ kind, percentUsed, resetsAt }) =>
40    resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt },
41  )
42}
43
44async function costDir($: EngineInterface): Promise<string> {
45  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
46  const home = await $.env.get('HOME')
47  const base = configDir ?? `${home ?? ''}/.claude`
48
49  return `${base}/burnrate/cost`
50}
51
52async function readLedger($: EngineInterface, path: string): Promise<BurnrateCostLedger | null> {
53  if (!(await $.fs.exists(path))) return null
54
55  return parseLedger(await $.fs.read(path))
56}
57
58async function sessionLabel($: EngineInterface): Promise<string> {
59  const root = await $.session.root()
60
61  return root.split('/').filter(Boolean).at(-1) ?? UNLABELED
62}
63
64async function recordSessionCost($: EngineInterface, dir: string, sessionId: string, usd: number, now: number): Promise<void> {
65  const path = `${dir}/${sessionId}.json`
66  const ledger = await readLedger($, path)
67  const recorded = { ...recordCost(ledger, usd, now), label: ledger?.label ?? (await sessionLabel($)) }
68  if (ledger !== null && JSON.stringify(ledger) === JSON.stringify(recorded)) return
69
70  await $.fs.write(path, JSON.stringify(recorded))
71}
72
73async function loadSessions($: EngineInterface, dir: string, now: number): Promise<BurnrateSessionCost[]> {
74  if (!(await $.fs.exists(dir))) return []
75
76  const entries = await $.fs.list(dir)
77  const recent = entries.filter(
78    entry => entry.kind === 'file' && entry.name.endsWith('.json') && entry.mtimeMs >= now - RETENTION_MS,
79  )
80  const ledgers = await Promise.all(
81    recent.map(async entry => {
82      const ledger = await readLedger($, `${dir}/${entry.name}`)
83      if (ledger === null) return []
84
85      const id = entry.name.slice(0, -'.json'.length)
86
87      return [{ id, label: ledger.label ?? UNLABELED, hours: ledger.hours }]
88    }),
89  )
90
91  return ledgers.flat()
92}
93
94async function readState($: EngineInterface): Promise<BurnrateSnapshot> {
95  const { value } = await $.state.get(snapshot)
96
97  return { ...EMPTY_SNAPSHOT, ...value, history: parseHistory(value?.history) }
98}
99
100async function isNewReading($: EngineInterface, readings: readonly BurnrateReading[]): Promise<boolean> {
101  const last = await $.store.get(LAST_READINGS_KEY)
102
103  return JSON.stringify(last) !== JSON.stringify(readings)
104}
105
106async function recordedHistory(
107  $: EngineInterface,
108  stored: BurnrateHistory,
109  readings: readonly BurnrateReading[],
110  now: number,
111  isFresh: boolean,
112): Promise<BurnrateHistory> {
113  if (!isFresh && !(await isNewReading($, readings))) return stored
114
115  const history = recordReadings(stored, readings, now, localDate(now))
116  if (history !== stored) await $.store.set(STORE_KEY, history)
117  await $.store.set(LAST_READINGS_KEY, readings)
118
119  return history
120}
121
122async function sentKeys($: EngineInterface, notices: readonly Notice[]): Promise<string[]> {
123  const sent = await Promise.all(
124    notices.map(notice =>
125      $.ui.notify(notice.text, { title: 'burnrate' }).then(
126        () => [notice.key],
127        () => [],
128      ),
129    ),
130  )
131
132  return sent.flat()
133}
134
135async function raiseAlerts($: EngineInterface, insights: readonly WindowInsight[], now: number): Promise<void> {
136  const previous = parseAlertState(await $.store.get(ALERTS_KEY))
137  const { toasts, notices, state } = alertsFor(insights, previous, now)
138
139  for (const text of toasts) $.ui.toast(text, { timeoutMs: ALERT_TOAST_MS })
140
141  const notified = withNotified(state, await sentKeys($, notices))
142  if (JSON.stringify(notified) !== JSON.stringify(previous)) await $.store.set(ALERTS_KEY, notified)
143}
144
145async function reportLastWeek($: EngineInterface, insights: readonly WindowInsight[]): Promise<void> {
146  const report = insights.find(insight => insight.lastWeek !== null)?.lastWeek
147  if (report === undefined || report === null || (await $.store.get(REPORTED_WEEK_KEY)) === report.key) return
148
149  await $.store.set(REPORTED_WEEK_KEY, report.key)
150  $.ui.toast(reportText(report), { timeoutMs: ALERT_TOAST_MS })
151}
152
153async function refreshNow(
154  $: EngineInterface,
155  settings: Settings,
156  rateLimits: readonly BurnrateReading[],
157  usd: number | undefined,
158  isFresh: boolean,
159): Promise<void> {
160  const now = await $.clock.now()
161  const stored = parseHistory(await $.store.get(STORE_KEY))
162  const readings = withPeaks(toReadings(rateLimits), stored)
163  const history = await recordedHistory($, stored, readings, now, isFresh)
164
165  const dir = await costDir($)
166  const sessionId = await $.session.id()
167  if (usd !== undefined) await recordSessionCost($, dir, sessionId, usd, now)
168
169  const sessions = await loadSessions($, dir, now)
170  const costs = mergeCosts(sessions, now)
171  const model = await $.session.model()
172  const next: BurnrateSnapshot = { readings, history, costs, sessions, sessionId, model, now }
173  await $.state.set(snapshot, next)
174
175  if (!settings.isAlertsOn) return
176
177  const insights = insightsOf(next)
178  await raiseAlerts($, insights, now)
179  await reportLastWeek($, insights)
180}
181
182function refresh(
183  $: EngineInterface,
184  settings: Settings,
185  rateLimits: readonly BurnrateReading[],
186  usd: number | undefined,
187  isFresh: boolean,
188): Promise<void> {
189  const run = pending.then(() => refreshNow($, settings, rateLimits, usd, isFresh))
190  pending = run.catch(() => undefined)
191
192  return run
193}
194
195function ensureTicker($: EngineInterface, settings: Settings): void {
196  if (ticker !== undefined) return
197
198  ticker = $.clock.every(TICK_MS, () => {
199    void $.session
200      .usage()
201      .then(usage => refresh($, settings, usage.rateLimits, usage.cost?.usd, false))
202      .catch((error: unknown) => $.ui.log(`burnrate refresh failed: ${String(error)}`, { to: 'debug' }))
203  })
204}
205
206async function suggestSonnet($: EngineInterface): Promise<void> {
207  const draft = await $.prompt.read()
208  if (draft.text.trim() !== '') return $.ui.toast('Clear the prompt to switch to Sonnet')
209
210  await $.prompt.fill({ text: SONNET_COMMAND })
211}
212
213async function togglePane($: EngineInterface): Promise<void> {
214  const panes = await $.ui.panes()
215  const isOpen = panes.some(pane => pane.id === PANE)
216  if (isOpen) return $.ui.close({ id: PANE })
217
218  await $.ui.open({ id: PANE, title: 'burnrate' })
219}
220
221export const register: Register = (on, options) => {
222  const isBandOn = options.statusLine !== false
223  const settings: Settings = { isAlertsOn: options.alerts !== false }
224
225  on('session.start', async ($, e, next) => {
226    await $.command.register({ name: 'burnrate', description: 'Toggle the burnrate quota pane', immediate: true })
227    const usage = await $.session.usage()
228    await refresh($, settings, usage.rateLimits, usage.cost?.usd, false)
229    ensureTicker($, settings)
230
231    return next(e)
232  })
233
234  on('command.run', { command: 'burnrate' }, async $ => {
235    ensureTicker($, settings)
236    await togglePane($)
237
238    return {}
239  })
240
241  on('session.measure', async ($, e, next) => {
242    ensureTicker($, settings)
243    const isFresh = e.changed.includes('rateLimits')
244    if (isFresh || e.changed.includes('cost')) await refresh($, settings, e.rateLimits, e.cost?.usd, isFresh)
245
246    return next(e)
247  })
248
249  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250    const { Box, Text } = $.ui.resolve(e)
251    const elements: Elements = { Box, Text }
252    const state = await readState($)
253
254    return paneTree(elements, insightsOf(state), localDate(state.now), e.props.bodyColumns)
255  })
256
257  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258    if (!isBandOn || e.props.hasSurvey) return next(e)
259
260    const { Box, Button, Text } = $.ui.resolve(e)
261    const state = await readState($)
262    const logged = (action: Promise<void>) => {
263      void action.catch((error: unknown) => $.ui.log(`burnrate action failed: ${String(error)}`, { to: 'debug' }))
264    }
265    const actions = { togglePane: () => logged(togglePane($)), suggestSonnet: () => logged(suggestSonnet($)) }
266    const tree = bandTree({ Box, Button, Text }, insightsOf(state), e.props.bodyColumns, state.model, actions)
267
268    return tree ?? next(e)
269  })
270}
271
hooks/alerts.ts 75 lines
1import type { BurnrateAlertState } from '../types'
2import { formatClock } from './format'
3import { isVerdict, VERDICT_SEVERITY, VERDICT_WORDS, type Verdict, type WindowInsight } from './insight'
4import { resetsAtOfKey } from './pace'
5
6export type Notice = { key: string; text: string }
7
8type Alerts = { toasts: string[]; notices: Notice[]; state: BurnrateAlertState }
9
10const IMMINENT_MS = 30 * 60_000
11const BASELINE: Verdict = 'on_track'
12const LABELS: Record<WindowInsight['view']['kind'], string> = { seven_day: '7d', five_hour: '5h' }
13
14function noticeFor({ view, verdict, hitAt }: WindowInsight, now: number): string | null {
15  const isWeek = view.kind === 'seven_day'
16  if (verdict === 'limit') return `${LABELS[view.kind]} limit reached, resets ${formatClock(view.resetsAt, isWeek)}`
17  if (hitAt === null || hitAt - now > IMMINENT_MS) return null
18
19  return `${LABELS[view.kind]} limit around ${formatClock(hitAt, isWeek)}, ease off now`
20}
21
22export function parseAlertState(value: unknown): BurnrateAlertState {
23  if (typeof value !== 'object' || value === null) return {}
24
25  const entries = Object.entries(value).flatMap(([key, entry]: [string, unknown]) => {
26    if (typeof entry !== 'object' || entry === null || !('verdict' in entry) || !('notified' in entry)) return []
27    if (!isVerdict(entry.verdict) || typeof entry.notified !== 'boolean') return []
28
29    return [[key, { verdict: entry.verdict, notified: entry.notified }] as const]
30  })
31
32  return Object.fromEntries(entries)
33}
34
35function isLive(key: string, now: number): boolean {
36  const resetsAt = resetsAtOfKey(key)
37
38  return resetsAt !== null && resetsAt > now
39}
40
41export function alertsFor(insights: readonly WindowInsight[], previous: BurnrateAlertState, now: number): Alerts {
42  const toasts: string[] = []
43  const notices: Notice[] = []
44  const state: BurnrateAlertState = Object.fromEntries(Object.entries(previous).filter(([key]) => isLive(key, now)))
45
46  for (const insight of insights) {
47    const { view, verdict } = insight
48    const before = previous[view.key]
49    const beforeVerdict = isVerdict(before?.verdict) ? before.verdict : BASELINE
50    const severity = VERDICT_SEVERITY[verdict]
51    if (severity > VERDICT_SEVERITY[BASELINE] && severity > VERDICT_SEVERITY[beforeVerdict]) {
52      toasts.push(`burnrate · ${LABELS[view.kind]} ${VERDICT_WORDS[verdict]}`)
53    }
54
55    const isNotified = before?.notified === true
56    const notice = isNotified ? null : noticeFor(insight, now)
57    if (notice !== null) notices.push({ key: view.key, text: notice })
58
59    const worst = VERDICT_SEVERITY[verdict] > VERDICT_SEVERITY[beforeVerdict] ? verdict : beforeVerdict
60    state[view.key] = { verdict: worst, notified: isNotified }
61  }
62
63  return { toasts, notices, state }
64}
65
66export function withNotified(state: BurnrateAlertState, keys: readonly string[]): BurnrateAlertState {
67  const notified = keys.flatMap(key => {
68    const entry = state[key]
69
70    return entry === undefined ? [] : [[key, { ...entry, notified: true }] as const]
71  })
72
73  return { ...state, ...Object.fromEntries(notified) }
74}
75
hooks/band.tsx 207 lines
1import type { ButtonProps, ElementConstructor, RenderElement } from 'claude-code'
2
3import { formatPct, formatUsd } from './format'
4import { VERDICT_SEVERITY, VERDICT_WORDS, worstVerdict, type Verdict, type WindowInsight } from './insight'
5import { EVEN_DAY_PCT, projectionTone, type DayUsage } from './pace'
6import { dayScale, smallBar, toneProps, VERDICT_COLORS, type Elements } from './pane'
7
8const SPARK_BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
9const WIDE_COLUMNS = 82
10const GAP_COLUMNS = 3
11const TODAY_BAR_WIDTH = 5
12const ADVICE_SEVERITY = VERDICT_SEVERITY.ahead
13const RANKS = { spark: 0, session: 1, today: 2, budget: 3, advice: 4, hours: 5, week: 6 } as const
14
15type Part = { key: string; text: string; node: RenderElement; rank: number }
16
17type BandElements = Elements & { Button: ElementConstructor<ButtonProps> }
18
19type BandActions = { togglePane: () => void; suggestSonnet: () => void }
20
21function sparkGlyph(used: number, top: number): string {
22  return SPARK_BARS[Math.min(SPARK_BARS.length - 1, Math.floor((used / top) * SPARK_BARS.length))] ?? ''
23}
24
25export function sparkGlyphs(days: readonly DayUsage[]): string[] {
26  const top = dayScale(days)
27
28  return days.map(day => sparkGlyph(day.used, top))
29}
30
31function sessionPart({ Text }: Elements, { spend, size }: WindowInsight): Part | null {
32  if (spend === null || spend.sessionUsd <= 0) return null
33
34  const value = size === null ? formatUsd(spend.sessionUsd) : formatPct(spend.sessionUsd / size.usdPerPoint)
35  const node = (
36    <Text>
37      <Text dimColor>session </Text>
38      {value}
39    </Text>
40  )
41
42  return { key: 'session', text: `session ${value}`, node, rank: RANKS.session }
43}
44
45function projectionPart({ Text }: Elements, { view }: WindowInsight): Part {
46  if (view.projected === null) {
47    const node = (
48      <Text>
49        <Text dimColor>7d </Text>
50        {formatPct(view.pct)}
51      </Text>
52    )
53
54    return { key: 'week', text: `7d ${formatPct(view.pct)}`, node, rank: RANKS.week }
55  }
56
57  const projected = Math.round(view.projected)
58  const node = (
59    <Text>
60      <Text dimColor>7d → </Text>
61      <Text {...toneProps(projectionTone(view.projected))}>{projected}%</Text>
62    </Text>
63  )
64
65  return { key: 'week', text: `7d → ${projected}%`, node, rank: RANKS.week }
66}
67
68function todayPart(elements: Elements, { view }: WindowInsight, columns: number): Part | null {
69  if (view.today === null) return null
70
71  const { Text } = elements
72  const { used, budget, tone } = view.today
73  const isWide = columns >= WIDE_COLUMNS
74  const figures = `${Math.round(used)}/${formatPct(budget)}`
75  const node = (
76    <Text>
77      <Text dimColor>today </Text>
78      {isWide && smallBar(elements, budget > 0 ? used / budget : 1, TODAY_BAR_WIDTH, tone)}
79      {isWide && ' '}
80      <Text {...toneProps(tone)}>{Math.round(used)}</Text>
81      <Text dimColor>/{formatPct(budget)}</Text>
82    </Text>
83  )
84  const bar = isWide ? `${'█'.repeat(TODAY_BAR_WIDTH)} ` : ''
85
86  return { key: 'today', text: `today ${bar}${figures}`, node, rank: RANKS.today }
87}
88
89function budgetPart({ Text }: Elements, { view }: WindowInsight): Part | null {
90  if (view.budget === null) return null
91
92  const value = formatPct(view.budget.value)
93  const node = (
94    <Text>
95      <Text {...toneProps(view.budget.tone)}>{value}</Text>
96      <Text dimColor>/d</Text>
97    </Text>
98  )
99
100  return { key: 'budget', text: `${value}/d`, node, rank: RANKS.budget }
101}
102
103function sparkPart({ Text }: Elements, { days }: WindowInsight): Part | null {
104  if (days.length < 2) return null
105
106  const glyphs = sparkGlyphs(days)
107  const node = (
108    <Text>
109      {days.map((day, index) => (
110        <Text color={day.used > EVEN_DAY_PCT ? 'warning' : 'success'}>{glyphs[index]}</Text>
111      ))}
112    </Text>
113  )
114
115  return { key: 'spark', text: glyphs.join(''), node, rank: RANKS.spark }
116}
117
118function weekParts(elements: Elements, insight: WindowInsight, columns: number): Part[] {
119  const optional = [
120    budgetPart(elements, insight),
121    todayPart(elements, insight, columns),
122    sessionPart(elements, insight),
123    sparkPart(elements, insight),
124  ]
125
126  return [projectionPart(elements, insight), ...optional.filter(part => part !== null)]
127}
128
129function headPart({ Text }: Elements, verdict: Verdict): Part {
130  const word = `● ${VERDICT_WORDS[verdict]}`
131  const node = (
132    <Text color={VERDICT_COLORS[verdict]}>
133      ▌ <Text bold>{word}</Text>
134    </Text>
135  )
136
137  return { key: 'verdict', text: `▌ ${word}`, node, rank: Number.POSITIVE_INFINITY }
138}
139
140function hoursPart({ Text }: Elements, { view, verdict }: WindowInsight): Part {
141  const pct = formatPct(view.pct)
142  const node = (
143    <Text>
144      <Text dimColor>5h </Text>
145      <Text color={VERDICT_COLORS[verdict]}>{pct}</Text>
146    </Text>
147  )
148
149  return { key: 'hours', text: `5h ${pct}`, node, rank: RANKS.hours }
150}
151
152function advicePart({ Text }: Elements, verdict: Verdict, model: string): Part | null {
153  const isAdvised = VERDICT_SEVERITY[verdict] >= ADVICE_SEVERITY && model.toLowerCase().includes('opus')
154  if (!isAdvised) return null
155
156  return { key: 'advice', text: 'try sonnet', node: <Text color="warning">try sonnet</Text>, rank: RANKS.advice }
157}
158
159function lineWidth(parts: readonly Part[]): number {
160  return parts.reduce((sum, part) => sum + [...part.text].length + GAP_COLUMNS, -GAP_COLUMNS)
161}
162
163function fitting(parts: readonly Part[], columns: number): Part[] {
164  if (lineWidth(parts) <= columns) return [...parts]
165
166  const droppable = parts.filter(part => Number.isFinite(part.rank))
167  if (droppable.length === 0) return [...parts]
168
169  const lowest = Math.min(...droppable.map(part => part.rank))
170
171  return fitting(
172    parts.filter(part => part.rank !== lowest),
173    columns,
174  )
175}
176
177export function bandTree(
178  elements: BandElements,
179  insights: readonly WindowInsight[],
180  columns: number,
181  model: string,
182  actions: BandActions,
183): RenderElement | null {
184  const verdict = worstVerdict(insights)
185  if (verdict === null) return null
186
187  const { Box, Button } = elements
188  const week = insights.find(insight => insight.view.kind === 'seven_day')
189  const hours = insights.find(insight => insight.view.kind === 'five_hour')
190  const optional = [hours === undefined ? null : hoursPart(elements, hours), advicePart(elements, verdict, model)]
191  const parts = [
192    headPart(elements, verdict),
193    ...(week === undefined ? [] : weekParts(elements, week, columns)),
194    ...optional.filter(part => part !== null),
195  ]
196
197  return (
198    <Box flexDirection="row" columnGap={GAP_COLUMNS}>
199      {fitting(parts, columns).map(part => (
200        <Button plain key={part.key} onPress={part.key === 'advice' ? actions.suggestSonnet : actions.togglePane}>
201          {part.node}
202        </Button>
203      ))}
204    </Box>
205  )
206}
207
hooks/insight.ts 101 lines
1import type { BurnrateSnapshot } from '../types'
2import { dailyUsage, describeWindow, localDate, WEEK_MS, type DayUsage, type WindowKind, type WindowView } from './pace'
3import { detectQuotaChange, quotaUsdPerPoint, windowRatios, type QuotaChange } from './quota-size'
4import { lastWeekReport, type WeekReport } from './report'
5import { weekSpend, type WeekSpend } from './sessions'
6
7export type Verdict = 'room' | 'on_track' | 'ahead' | 'will_hit' | 'limit'
8
9type QuotaSize = { usdPerPoint: number; change: QuotaChange | null }
10
11export type WindowInsight = {
12  view: WindowView
13  verdict: Verdict
14  hitAt: number | null
15  size: QuotaSize | null
16  days: DayUsage[]
17  spend: WeekSpend | null
18  lastWeek: WeekReport | null
19}
20
21export const VERDICT_WORDS: Record<Verdict, string> = {
22  room: 'go wild',
23  on_track: 'cruising',
24  ahead: 'easy tiger',
25  will_hit: 'hit the brakes',
26  limit: 'tapped out',
27}
28export const VERDICT_SEVERITY: Record<Verdict, number> = { room: 0, on_track: 1, ahead: 2, will_hit: 3, limit: 4 }
29
30const AHEAD_RATIO = 0.85
31const ROOM_RATIO = 1.15
32const ROOM_MAX_PROJECTION = 85
33const KIND_ORDER: Record<WindowKind, number> = { seven_day: 0, five_hour: 1 }
34
35export function isVerdict(value: unknown): value is Verdict {
36  return typeof value === 'string' && value in VERDICT_SEVERITY
37}
38
39export function worstVerdict(insights: readonly WindowInsight[]): Verdict | null {
40  return insights.reduce<Verdict | null>(
41    (worst, { verdict }) => (worst === null || VERDICT_SEVERITY[verdict] > VERDICT_SEVERITY[worst] ? verdict : worst),
42    null,
43  )
44}
45
46export function verdictOf(view: WindowView): Verdict {
47  if (view.pct >= 100) return 'limit'
48  if (view.projected !== null && view.projected > 100) return 'will_hit'
49
50  const ratio = view.budget?.ratio
51  if (ratio === undefined) return 'on_track'
52  if (ratio < AHEAD_RATIO) return 'ahead'
53  if (ratio >= ROOM_RATIO && (view.projected ?? 0) <= ROOM_MAX_PROJECTION) return 'room'
54
55  return 'on_track'
56}
57
58function hitTime(view: WindowView, now: number): number | null {
59  if (view.projected === null || view.projected <= 100 || view.secondsLeft <= 0) return null
60
61  const pctPerSecond = (view.projected - view.pct) / view.secondsLeft
62  if (pctPerSecond <= 0) return null
63
64  return now + ((100 - view.pct) / pctPerSecond) * 1_000
65}
66
67function quotaSize(state: BurnrateSnapshot, view: WindowView): QuotaSize | null {
68  const ratios = windowRatios(state.history.hours, state.costs, view.kind, state.now)
69  const usdPerPoint = quotaUsdPerPoint(ratios, view.key)
70  if (usdPerPoint === null) return null
71
72  const change = view.kind === 'seven_day' ? detectQuotaChange(ratios, view.key) : null
73
74  return { usdPerPoint, change }
75}
76
77export function insightsOf(state: BurnrateSnapshot): WindowInsight[] {
78  const today = localDate(state.now)
79  const views = state.readings.flatMap(reading => {
80    const view = describeWindow(reading, state.history, state.now, today)
81
82    return view === null ? [] : [view]
83  })
84
85  return views
86    .sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind])
87    .map(view => {
88      const isWeek = view.kind === 'seven_day'
89
90      return {
91        view,
92        verdict: verdictOf(view),
93        hitAt: hitTime(view, state.now),
94        size: quotaSize(state, view),
95        days: isWeek ? dailyUsage(state.history, view.key, view.pct) : [],
96        spend: isWeek ? weekSpend(state.sessions, view.resetsAt - WEEK_MS, state.sessionId) : null,
97        lastWeek: isWeek ? lastWeekReport(state.history, view.resetsAt, state.now) : null,
98      }
99    })
100}
101
hooks/pace.ts 401 lines
1import type { BurnrateDayStart, BurnrateHistory, BurnrateHourSample, BurnratePeak, BurnrateReading } from '../types'
2import { outlookOf, type Outlook, type PastWindow, type TracePoint } from './outlook'
3
4export type WindowKind = 'five_hour' | 'seven_day'
5
6export type Tone = 'normal' | 'dim' | 'warn' | 'alert'
7
8type Budget = { value: number; unit: 'd' | 'h'; ratio: number; tone: Tone }
9
10type Today = { used: number; budget: number; tone: Tone }
11
12export type DayUsage = { date: string; used: number }
13
14type ProjectionBasis = 'analog' | 'typical' | 'linear'
15
16export type WindowView = {
17  kind: WindowKind
18  key: string
19  pct: number
20  resetsAt: number
21  secondsLeft: number
22  elapsedPct: number
23  basis: ProjectionBasis
24  analogSample: number
25  projected: number | null
26  projectionTone: Tone
27  budget: Budget | null
28  today: Today | null
29}
30
31const MINUTE_MS = 60_000
32const HOUR_SECONDS = 3_600
33const DAY_SECONDS = 86_400
34const KEY_ROUNDING_MS = 10 * MINUTE_MS
35const ANALOG_WINDOWS = 6
36const MIN_LINEAR_ELAPSED = 0.1
37const ELAPSED_EPSILON = 1e-9
38
39export const HOUR_MS = HOUR_SECONDS * 1_000
40export const DAY_MS = DAY_SECONDS * 1_000
41export const WEEK_MS = 7 * DAY_MS
42export const RETENTION_MS = 49 * DAY_MS
43export const EVEN_DAY_PCT = 100 / 7
44
45const DAY_START_RETENTION_MS = 30 * DAY_MS
46
47export const WINDOW_SECONDS: Record<WindowKind, number> = {
48  five_hour: 5 * HOUR_SECONDS,
49  seven_day: 7 * DAY_SECONDS,
50}
51
52export const EMPTY_HISTORY: BurnrateHistory = { peaks: {}, dayStarts: {}, hours: {} }
53
54export function isWindowKind(kind: string): kind is WindowKind {
55  return kind === 'five_hour' || kind === 'seven_day'
56}
57
58function roundResetsAt(resetsAtMs: number): number {
59  return Math.round(resetsAtMs / KEY_ROUNDING_MS) * KEY_ROUNDING_MS
60}
61
62export function windowKey(kind: WindowKind, resetsAtMs: number): string {
63  return `${kind}@${roundResetsAt(resetsAtMs)}`
64}
65
66export function resetsAtOfKey(key: string): number | null {
67  const resetsAt = Number(key.split('@')[1])
68
69  return Number.isFinite(resetsAt) ? resetsAt : null
70}
71
72export function localDate(ms: number): string {
73  const date = new Date(ms)
74  const month = String(date.getMonth() + 1).padStart(2, '0')
75  const day = String(date.getDate()).padStart(2, '0')
76
77  return `${date.getFullYear()}-${month}-${day}`
78}
79
80function resetsAtMs(reading: BurnrateReading): number | null {
81  if (reading.resetsAt === undefined) return null
82
83  const parsed = Date.parse(reading.resetsAt)
84
85  return Number.isFinite(parsed) ? parsed : null
86}
87
88function withPeak(history: BurnrateHistory, kind: WindowKind, resetsAt: number, pct: number): BurnrateHistory {
89  const key = windowKey(kind, resetsAt)
90  const previous = history.peaks[key]
91  if (previous !== undefined && previous.peak >= pct) return history
92
93  const peak: BurnratePeak = { kind, resetsAt: roundResetsAt(resetsAt), peak: pct }
94
95  return { ...history, peaks: { ...history.peaks, [key]: peak } }
96}
97
98function withDayStart(history: BurnrateHistory, key: string, pct: number, now: number, today: string): BurnrateHistory {
99  const dayKey = `${key}#${today}`
100  if (history.dayStarts[dayKey] !== undefined) return history
101
102  const start: BurnrateDayStart = { windowKey: key, date: today, pct, seenAt: now }
103
104  return { ...history, dayStarts: { ...history.dayStarts, [dayKey]: start } }
105}
106
107function withHourSample(history: BurnrateHistory, key: string, pct: number, now: number): BurnrateHistory {
108  const hour = Math.floor(now / HOUR_MS)
109  const hourKey = `${key}#${hour}`
110  const previous = history.hours[hourKey]
111  if (previous !== undefined && previous.pct >= pct) return history
112
113  const sample: BurnrateHourSample = { windowKey: key, hour, pct }
114
115  return { ...history, hours: { ...history.hours, [hourKey]: sample } }
116}
117
118function kept<T>(entries: Record<string, T>, isKept: (entry: T) => boolean): Record<string, T> {
119  const all = Object.entries(entries)
120  const filtered = all.filter(([, entry]) => isKept(entry))
121  if (filtered.length === all.length) return entries
122
123  return Object.fromEntries(filtered)
124}
125
126function pruned(history: BurnrateHistory, now: number): BurnrateHistory {
127  const oldestPeak = now - RETENTION_MS
128  const oldestHour = Math.floor((now - RETENTION_MS) / HOUR_MS)
129  const peaks = kept(history.peaks, peak => peak.resetsAt >= oldestPeak)
130  const dayStarts = kept(history.dayStarts, start => start.seenAt >= now - DAY_START_RETENTION_MS)
131  const hours = kept(history.hours, sample => sample.hour >= oldestHour)
132  if (peaks === history.peaks && dayStarts === history.dayStarts && hours === history.hours) return history
133
134  return { peaks, dayStarts, hours }
135}
136
137function lastDayPeak(history: BurnrateHistory, key: string, now: number): number | undefined {
138  const oldestHour = Math.floor((now - DAY_MS) / HOUR_MS)
139  const isRecent = Object.values(history.hours).some(sample => sample.windowKey === key && sample.hour >= oldestHour)
140
141  return isRecent ? history.peaks[key]?.peak : undefined
142}
143
144export function recordReadings(
145  history: BurnrateHistory,
146  readings: readonly BurnrateReading[],
147  now: number,
148  today: string,
149): BurnrateHistory {
150  const recorded = readings.reduce((current, reading) => {
151    const resetsAt = resetsAtMs(reading)
152    if (resetsAt === null || !isWindowKind(reading.kind)) return current
153
154    const key = windowKey(reading.kind, resetsAt)
155    const previousPeak = lastDayPeak(current, key, now)
156    const peaked = withPeak(current, reading.kind, resetsAt, reading.percentUsed)
157    const sampled = withHourSample(peaked, key, reading.percentUsed, now)
158    if (reading.kind !== 'seven_day') return sampled
159
160    return withDayStart(sampled, key, previousPeak ?? reading.percentUsed, now, today)
161  }, history)
162
163  return pruned(recorded, now)
164}
165
166export function withPeaks(readings: readonly BurnrateReading[], history: BurnrateHistory): BurnrateReading[] {
167  return readings.map(reading => {
168    const resetsAt = resetsAtMs(reading)
169    if (resetsAt === null || !isWindowKind(reading.kind)) return reading
170
171    const peak = history.peaks[windowKey(reading.kind, resetsAt)]?.peak ?? reading.percentUsed
172
173    return { ...reading, percentUsed: Math.max(reading.percentUsed, peak) }
174  })
175}
176
177export function closedPeaks(history: BurnrateHistory, kind: WindowKind, resetsAt: number, now: number): BurnratePeak[] {
178  const current = roundResetsAt(resetsAt)
179
180  return Object.values(history.peaks)
181    .filter(peak => peak.kind === kind && peak.resetsAt < current && peak.resetsAt <= now)
182    .sort((a, b) => b.resetsAt - a.resetsAt)
183    .slice(0, ANALOG_WINDOWS)
184}
185
186function tracePoints(history: BurnrateHistory, peak: BurnratePeak, kind: WindowKind): TracePoint[] {
187  const key = windowKey(kind, peak.resetsAt)
188  const windowSeconds = WINDOW_SECONDS[kind]
189  const startMs = peak.resetsAt - windowSeconds * 1_000
190
191  return Object.values(history.hours)
192    .filter(sample => sample.windowKey === key)
193    .map(sample => {
194      const elapsedSeconds = ((sample.hour + 1) * HOUR_MS - startMs) / 1_000
195
196      return { elapsedSeconds: Math.min(windowSeconds, Math.max(0, elapsedSeconds)), pct: sample.pct }
197    })
198    .sort((a, b) => a.elapsedSeconds - b.elapsedSeconds)
199}
200
201function pastWindowOf(history: BurnrateHistory, peak: BurnratePeak, kind: WindowKind, elapsedSeconds: number): PastWindow {
202  const points = tracePoints(history, peak, kind)
203  const before = points.filter(point => point.elapsedSeconds <= elapsedSeconds).at(-1) ?? null
204  const after = points.find(point => point.elapsedSeconds > elapsedSeconds) ?? null
205
206  return { peak: peak.peak, before, after }
207}
208
209export function outlookFor(
210  history: BurnrateHistory,
211  kind: WindowKind,
212  resetsAt: number,
213  now: number,
214  elapsedSeconds: number,
215): Outlook | null {
216  const pastWindows = closedPeaks(history, kind, resetsAt, now).map(peak => pastWindowOf(history, peak, kind, elapsedSeconds))
217
218  return outlookOf(pastWindows, elapsedSeconds, WINDOW_SECONDS[kind])
219}
220
221type ProjectionInput = {
222  pct: number
223  secondsLeft: number
224  windowSeconds: number
225  outlook: Outlook | null
226}
227
228export function projectAtReset({ pct, secondsLeft, windowSeconds, outlook }: ProjectionInput): number | null {
229  const elapsed = Math.min(1, Math.max(0, 1 - secondsLeft / windowSeconds))
230
231  if (outlook === null) {
232    if (elapsed < MIN_LINEAR_ELAPSED - ELAPSED_EPSILON) return null
233
234    return pct / elapsed
235  }
236
237  if (outlook.remaining !== null) return pct + outlook.remaining
238
239  const remaining = 1 - elapsed
240  const blended = pct + remaining * outlook.typical
241
242  return pct + remaining * blended
243}
244
245function basisOf(outlook: Outlook | null): ProjectionBasis {
246  if (outlook === null) return 'linear'
247
248  return outlook.remaining === null ? 'typical' : 'analog'
249}
250
251export function projectionTone(projected: number | null): Tone {
252  if (projected === null) return 'dim'
253  if (projected > 100) return 'alert'
254  if (projected > 85) return 'warn'
255
256  return 'normal'
257}
258
259function paceTone(ratio: number): Tone {
260  if (ratio < 0.5) return 'alert'
261  if (ratio < 0.85) return 'warn'
262
263  return 'dim'
264}
265
266export function budgetLeft(kind: WindowKind, pct: number, secondsLeft: number): Budget | null {
267  if (secondsLeft <= 0) return null
268
269  const unitSeconds = kind === 'seven_day' ? DAY_SECONDS : HOUR_SECONDS
270  const evenSplit = (100 * unitSeconds) / WINDOW_SECONDS[kind]
271  const value = Math.max(0, 100 - pct) / (secondsLeft / unitSeconds)
272  const unit = kind === 'seven_day' ? 'd' : 'h'
273  const ratio = value / evenSplit
274
275  return { value, unit, ratio, tone: paceTone(ratio) }
276}
277
278export function todayUsage(pct: number, start: BurnrateDayStart, resetsAt: number): Today | null {
279  const daysLeft = (resetsAt - start.seenAt) / DAY_MS
280  if (daysLeft <= 0) return null
281
282  const used = Math.max(0, pct - start.pct)
283  const headroom = Math.max(0, 100 - start.pct)
284  const budget = Math.min(headroom, headroom / daysLeft)
285
286  return { used, budget, tone: todayTone(used, budget) }
287}
288
289function todayTone(used: number, budget: number): Tone {
290  if (used > budget) return 'alert'
291  if (used > 0.85 * budget) return 'warn'
292
293  return 'dim'
294}
295
296function startsOf(history: BurnrateHistory, key: string): BurnrateDayStart[] {
297  return Object.values(history.dayStarts)
298    .filter(start => start.windowKey === key)
299    .sort((a, b) => a.seenAt - b.seenAt)
300}
301
302export function dailyUsage(history: BurnrateHistory, key: string, pct: number): DayUsage[] {
303  const starts = startsOf(history, key)
304
305  return starts.map((start, index) => {
306    const end = starts[index + 1]?.pct ?? pct
307
308    return { date: start.date, used: Math.max(0, end - start.pct) }
309  })
310}
311
312export function describeWindow(
313  reading: BurnrateReading,
314  history: BurnrateHistory,
315  now: number,
316  today: string,
317): WindowView | null {
318  const resetsAt = resetsAtMs(reading)
319  if (resetsAt === null || !isWindowKind(reading.kind)) return null
320
321  if (resetsAt <= now) return null
322
323  const kind = reading.kind
324  const pct = reading.percentUsed
325  const key = windowKey(kind, resetsAt)
326  const secondsLeft = Math.max(0, (resetsAt - now) / 1_000)
327  const windowSeconds = WINDOW_SECONDS[kind]
328  const outlook = outlookFor(history, kind, resetsAt, now, Math.max(0, windowSeconds - secondsLeft))
329  const projected = projectAtReset({ pct, secondsLeft, windowSeconds, outlook })
330  const start = history.dayStarts[`${key}#${today}`]
331  const todayView = kind === 'seven_day' && start !== undefined ? todayUsage(pct, start, resetsAt) : null
332
333  return {
334    kind,
335    key,
336    pct,
337    resetsAt,
338    secondsLeft,
339    elapsedPct: Math.min(100, Math.max(0, 100 * (1 - secondsLeft / WINDOW_SECONDS[kind]))),
340    basis: basisOf(outlook),
341    analogSample: outlook?.traced ?? 0,
342    projected,
343    projectionTone: projectionTone(projected),
344    budget: budgetLeft(kind, pct, secondsLeft),
345    today: todayView,
346  }
347}
348
349function isRecord(value: unknown): value is Record<string, unknown> {
350  return typeof value === 'object' && value !== null && !Array.isArray(value)
351}
352
353function asPeak(value: unknown): BurnratePeak | null {
354  if (!isRecord(value)) return null
355
356  const { kind, resetsAt, peak } = value
357  if (typeof kind !== 'string' || typeof resetsAt !== 'number' || typeof peak !== 'number') return null
358
359  return { kind, resetsAt, peak }
360}
361
362function asDayStart(value: unknown): BurnrateDayStart | null {
363  if (!isRecord(value)) return null
364
365  const { windowKey: key, date, pct, seenAt } = value
366  if (typeof key !== 'string' || typeof date !== 'string' || typeof pct !== 'number' || typeof seenAt !== 'number') return null
367
368  return { windowKey: key, date, pct, seenAt }
369}
370
371function asHourSample(value: unknown): BurnrateHourSample | null {
372  if (!isRecord(value)) return null
373
374  const { windowKey: key, hour, pct } = value
375  if (typeof key !== 'string' || typeof hour !== 'number' || typeof pct !== 'number') return null
376
377  return { windowKey: key, hour, pct }
378}
379
380function validEntries<T>(value: unknown, parse: (entry: unknown) => T | null): Record<string, T> {
381  if (!isRecord(value)) return {}
382
383  const entries = Object.entries(value).flatMap(([key, entry]) => {
384    const parsed = parse(entry)
385
386    return parsed === null ? [] : [[key, parsed] as const]
387  })
388
389  return Object.fromEntries(entries)
390}
391
392export function parseHistory(value: unknown): BurnrateHistory {
393  if (!isRecord(value)) return EMPTY_HISTORY
394
395  return {
396    peaks: validEntries(value.peaks, asPeak),
397    dayStarts: validEntries(value.dayStarts, asDayStart),
398    hours: validEntries(value.hours, asHourSample),
399  }
400}
401
hooks/pane.tsx 306 lines
1import type { BoxProps, Color, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2
3import { formatClock, formatDay, formatDuration, formatPct, formatUsd } from './format'
4import type { Verdict, WindowInsight } from './insight'
5import { EVEN_DAY_PCT, projectionTone, type DayUsage, type Tone, type WindowView } from './pace'
6
7export type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
8
9type GaugeCell = { text: string; tone: CellTone }
10
11type CellTone = 'filled' | 'empty' | 'marker'
12
13type Layout = { gaugeWidth: number; barWidth: number; isWide: boolean; isSideBySide: boolean }
14
15const WIDE_COLUMNS = 80
16const SIDE_BY_SIDE_COLUMNS = 120
17const GAUGE_MAX = 48
18const BAR_MAX = 24
19const TITLES: Record<WindowView['kind'], string> = { seven_day: '7-day window', five_hour: '5-hour window' }
20const UNITS: Record<WindowView['kind'], string> = { seven_day: 'day', five_hour: 'hour' }
21const PERIODS_LONG: Record<WindowView['kind'], string> = { seven_day: 'week', five_hour: 'window' }
22const PERIODS: Record<WindowView['kind'], string> = { seven_day: 'week', five_hour: '5h' }
23export const VERDICT_COLORS: Record<Verdict, Color> = {
24  room: 'success',
25  on_track: 'text',
26  ahead: 'warning',
27  will_hit: 'error',
28  limit: 'error',
29}
30
31function layoutFor(columns: number): Layout {
32  return {
33    gaugeWidth: Math.max(10, Math.min(GAUGE_MAX, columns - 8)),
34    barWidth: Math.max(4, Math.min(BAR_MAX, columns - 22)),
35    isWide: columns >= WIDE_COLUMNS,
36    isSideBySide: columns >= SIDE_BY_SIDE_COLUMNS,
37  }
38}
39
40export function toneProps(tone: Tone): TextProps {
41  if (tone === 'alert') return { color: 'error' }
42  if (tone === 'warn') return { color: 'warning' }
43  if (tone === 'dim') return { dimColor: true }
44
45  return {}
46}
47
48function gaugeCells(width: number, usedPct: number, markerPct: number): GaugeCell[] {
49  const filled = Math.round((Math.min(100, Math.max(0, usedPct)) / 100) * width)
50  const marker = Math.min(width - 1, Math.round((markerPct / 100) * width))
51  const cells = Array.from({ length: width }, (_, index): CellTone => {
52    if (index === marker) return 'marker'
53
54    return index < filled ? 'filled' : 'empty'
55  })
56
57  return cells.reduce<GaugeCell[]>((runs, tone) => {
58    const glyph = tone === 'filled' ? '█' : tone === 'marker' ? '│' : '░'
59    const last = runs.at(-1)
60    if (last?.tone === tone) return [...runs.slice(0, -1), { text: last.text + glyph, tone }]
61
62    return [...runs, { text: glyph, tone }]
63  }, [])
64}
65
66function verdictText({ view, verdict, hitAt }: WindowInsight): string {
67  if (verdict === 'limit') return 'Limit reached'
68  if (verdict === 'will_hit' && hitAt !== null) return `Will hit the limit around ${formatClock(hitAt, view.kind === 'seven_day')}`
69  if (verdict === 'will_hit') return 'Will hit the limit before reset'
70  if (verdict === 'ahead') return 'Ahead of pace: ease off'
71  if (verdict === 'room') return 'Room to spare'
72
73  return 'On track'
74}
75
76function gauge({ Box, Text }: Elements, insight: WindowInsight, width: number): RenderElement {
77  const color = VERDICT_COLORS[insight.verdict]
78  const runs = gaugeCells(width, insight.view.pct, insight.view.elapsedPct)
79
80  return (
81    <Box flexDirection="row">
82      {runs.map(run => {
83        if (run.tone === 'filled') return <Text color={color}>{run.text}</Text>
84        if (run.tone === 'marker') return <Text bold>{run.text}</Text>
85
86        return <Text dimColor>{run.text}</Text>
87      })}
88      <Text> {formatPct(insight.view.pct)}</Text>
89    </Box>
90  )
91}
92
93function usedLine({ Text }: Elements, view: WindowView): RenderElement {
94  const clock = formatClock(view.resetsAt, view.kind === 'seven_day')
95
96  return (
97    <Text>
98      Used {formatPct(view.pct)} <Text dimColor>· resets {clock} (in {formatDuration(view.secondsLeft)})</Text>
99    </Text>
100  )
101}
102
103function basisText(view: WindowView): string {
104  if (view.basis === 'linear') return 'linear, no history yet'
105  if (view.basis === 'typical') return `typical ${PERIODS_LONG[view.kind]}`
106
107  const plural = view.analogSample === 1 ? '' : 's'
108
109  return `from your last ${view.analogSample} ${PERIODS_LONG[view.kind]}${plural}`
110}
111
112function projectionLine({ Text }: Elements, { view, verdict }: WindowInsight, layout: Layout): RenderElement | null {
113  if (verdict === 'limit') return null
114  if (view.projected === null) return <Text dimColor>Projected at reset: too early to tell</Text>
115
116  return (
117    <Text>
118      Projected at reset <Text {...toneProps(projectionTone(view.projected))}>{Math.round(view.projected)}%</Text>
119      {layout.isWide && <Text dimColor> {basisText(view)}</Text>}
120    </Text>
121  )
122}
123
124function budgetLine({ Text }: Elements, { view, verdict }: WindowInsight, layout: Layout): RenderElement | null {
125  if (verdict === 'limit' || view.budget === null) return null
126
127  const value = `${formatPct(view.budget.value)}/${UNITS[view.kind]}`
128
129  return (
130    <Text>
131      Budget <Text {...toneProps(view.budget.tone)}>{value}</Text>
132      {layout.isWide && <Text dimColor> to land at 100%</Text>}
133    </Text>
134  )
135}
136
137export function dayScale(days: readonly DayUsage[]): number {
138  return Math.max(EVEN_DAY_PCT, ...days.map(day => day.used))
139}
140
141export function smallBar({ Text }: Elements, ratio: number, width: number, tone: Tone): RenderElement {
142  const filled = Math.min(width, Math.round(Math.max(0, ratio) * width))
143
144  return (
145    <Text>
146      <Text {...toneProps(tone === 'dim' ? 'normal' : tone)}>{'█'.repeat(filled)}</Text>
147      <Text dimColor>{'░'.repeat(width - filled)}</Text>
148    </Text>
149  )
150}
151
152function todayLine(elements: Elements, view: WindowView, layout: Layout): RenderElement | null {
153  if (view.today === null) return null
154
155  const { Box, Text } = elements
156  const { used, budget, tone } = view.today
157  const ratio = budget > 0 ? used / budget : 1
158
159  return (
160    <Box flexDirection="row" columnGap={1}>
161      <Text>
162        Today <Text {...toneProps(tone)}>{formatPct(used)}</Text> of {formatPct(budget)}
163      </Text>
164      {layout.isWide && smallBar(elements, ratio, 10, tone)}
165    </Box>
166  )
167}
168
169function dayRow(elements: Elements, day: DayUsage, scale: number, layout: Layout, isToday: boolean): RenderElement {
170  const { Box, Text } = elements
171  const tone: Tone = day.used > EVEN_DAY_PCT ? 'warn' : 'normal'
172
173  return (
174    <Box key={day.date} flexDirection="row" columnGap={1}>
175      <Text bold={isToday} dimColor={!isToday}>
176        {formatDay(day.date)}
177      </Text>
178      {smallBar(elements, day.used / scale, layout.barWidth, tone)}
179      <Text bold={isToday} dimColor={!isToday}>
180        {formatPct(day.used)}
181        {isToday ? ' today' : ''}
182      </Text>
183    </Box>
184  )
185}
186
187function daysBlock(elements: Elements, days: DayUsage[], today: string, layout: Layout): RenderElement | null {
188  if (days.length === 0) return null
189
190  const scale = dayScale(days)
191  const { Box } = elements
192
193  return <Box flexDirection="column">{days.map(day => dayRow(elements, day, scale, layout, day.date === today))}</Box>
194}
195
196function quotaLines({ Text }: Elements, { view, size }: WindowInsight): RenderElement[] {
197  if (size === null) return [<Text dimColor>Quota size: learning (needs 5 points)</Text>]
198
199  const quota = (
200    <Text>
201      Quota ≈ {formatUsd(size.usdPerPoint * 100)} / {PERIODS[view.kind]}{' '}
202      <Text dimColor>(1% ≈ {formatUsd(size.usdPerPoint)})</Text>
203    </Text>
204  )
205  if (size.change === null) return [quota]
206
207  const change = (
208    <Text color={size.change.direction === 'down' ? 'error' : 'success'}>
209      Quota {size.change.direction} ~{size.change.percent}% vs usual
210    </Text>
211  )
212
213  return [quota, change]
214}
215
216function usdAndPct(usd: number, size: WindowInsight['size']): string {
217  if (size === null) return formatUsd(usd)
218
219  return `${formatUsd(usd)} (${formatPct(usd / size.usdPerPoint)})`
220}
221
222function spendLines({ Box, Text }: Elements, { spend, size }: WindowInsight): RenderElement[] {
223  if (spend === null || spend.projects.length === 0) return []
224
225  const session = <Text>This session {usdAndPct(spend.sessionUsd, size)}</Text>
226  const projects = spend.projects.map(project => (
227    <Box key={project.label} flexDirection="row" columnGap={1}>
228      <Text dimColor>{project.label}</Text>
229      <Text>{usdAndPct(project.usd, size)}</Text>
230    </Box>
231  ))
232
233  return [session, <Text dimColor>Top projects this week</Text>, ...projects]
234}
235
236function lastWeekLine({ Text }: Elements, { lastWeek }: WindowInsight): RenderElement | null {
237  if (lastWeek === null) return null
238
239  return (
240    <Text>
241      Last week peaked <Text {...toneProps(projectionTone(lastWeek.peak))}>{formatPct(lastWeek.peak)}</Text>
242      {lastWeek.busiest !== null && (
243        <Text dimColor>
244          {' '}
245          · busiest {formatDay(lastWeek.busiest.date)} at {formatPct(lastWeek.busiest.used)}
246        </Text>
247      )}
248    </Text>
249  )
250}
251
252function section(elements: Elements, insight: WindowInsight, today: string, layout: Layout): RenderElement {
253  const { Box, Text } = elements
254  const { view } = insight
255
256  return (
257    <Box key={view.kind} flexDirection="column" marginBottom={1}>
258      <Text>
259        <Text bold>{TITLES[view.kind]}</Text>
260        <Text dimColor> · </Text>
261        <Text bold color={VERDICT_COLORS[insight.verdict]}>
262          {verdictText(insight)}
263        </Text>
264      </Text>
265      {gauge(elements, insight, layout.gaugeWidth)}
266      {usedLine(elements, view)}
267      {projectionLine(elements, insight, layout)}
268      {budgetLine(elements, insight, layout)}
269      {todayLine(elements, view, layout)}
270      {daysBlock(elements, insight.days, today, layout)}
271      {quotaLines(elements, insight)}
272      {spendLines(elements, insight)}
273      {lastWeekLine(elements, insight)}
274    </Box>
275  )
276}
277
278function footer({ Text }: Elements, layout: Layout): RenderElement {
279  if (!layout.isWide) return <Text dimColor>│ even pace now · $ API-equivalent, this machine</Text>
280
281  return <Text dimColor>│ marks where an even pace would be now. $ are API-equivalent and count this machine only.</Text>
282}
283
284export function paneTree(elements: Elements, insights: readonly WindowInsight[], today: string, columns: number): RenderElement {
285  const { Box, Text } = elements
286  if (insights.length === 0) {
287    return (
288      <Box flexDirection="column">
289        <Text bold>No quota data yet</Text>
290        <Text dimColor>Needs a subscription and one API response.</Text>
291      </Box>
292    )
293  }
294
295  const layout = layoutFor(columns)
296
297  return (
298    <Box flexDirection="column">
299      <Box flexDirection={layout.isSideBySide ? 'row' : 'column'} columnGap={4}>
300        {insights.map(insight => section(elements, insight, today, layout))}
301      </Box>
302      {footer(elements, layout)}
303    </Box>
304  )
305}
306
hooks/quota-size.ts 171 lines
1import type { BurnrateCostLedger, BurnrateHourSample } from '../types'
2import { HOUR_MS, isWindowKind, resetsAtOfKey, RETENTION_MS, WINDOW_SECONDS } from './pace'
3
4type HourlyCosts = Readonly<Record<string, number>>
5
6export type WindowRatio = {
7  windowKey: string
8  resetsAt: number
9  usd: number
10  points: number
11  usdPerPoint: number | null
12}
13
14export type QuotaChange = { direction: 'up' | 'down'; percent: number }
15
16const RETENTION_HOURS = RETENTION_MS / HOUR_MS
17const MIN_POINTS = 5
18const CHANGE_THRESHOLD = 0.2
19const MIN_CLOSED_WINDOWS = 2
20
21function costBetween(costs: HourlyCosts, fromHourExclusive: number, toHourInclusive: number): number {
22  let total = 0
23  for (let hour = fromHourExclusive + 1; hour <= toHourInclusive; hour += 1) total += costs[String(hour)] ?? 0
24
25  return total
26}
27
28export function windowRatio(windowKey: string, samples: readonly BurnrateHourSample[], costs: HourlyCosts): WindowRatio | null {
29  const resetsAt = resetsAtOfKey(windowKey)
30  if (resetsAt === null) return null
31
32  const ordered = [...samples].sort((a, b) => a.hour - b.hour)
33  const moves = ordered.flatMap((sample, index) => {
34    const previous = ordered[index - 1]
35    if (previous === undefined) return []
36
37    const gained = sample.pct - previous.pct
38    if (gained <= 0) return []
39
40    return [{ gained, usd: costBetween(costs, previous.hour, sample.hour) }]
41  })
42  const points = moves.reduce((sum, move) => sum + move.gained, 0)
43  const usd = moves.reduce((sum, move) => sum + move.usd, 0)
44  const usdPerPoint = points >= MIN_POINTS && usd > 0 ? usd / points : null
45
46  return { windowKey, resetsAt, usd, points, usdPerPoint }
47}
48
49function startsInCostRetention(ratio: WindowRatio, kind: string, now: number): boolean {
50  if (!isWindowKind(kind)) return false
51
52  const startHour = Math.floor((ratio.resetsAt - WINDOW_SECONDS[kind] * 1_000) / HOUR_MS)
53
54  return startHour >= retainedSince(now)
55}
56
57export function windowRatios(
58  hours: Readonly<Record<string, BurnrateHourSample>>,
59  costs: HourlyCosts,
60  kind: string,
61  now: number,
62): WindowRatio[] {
63  const byWindow = new Map<string, BurnrateHourSample[]>()
64  for (const sample of Object.values(hours)) {
65    if (!sample.windowKey.startsWith(`${kind}@`)) continue
66
67    byWindow.set(sample.windowKey, [...(byWindow.get(sample.windowKey) ?? []), sample])
68  }
69
70  return [...byWindow.entries()]
71    .flatMap(([key, samples]) => {
72      const ratio = windowRatio(key, samples, costs)
73
74      return ratio === null || !startsInCostRetention(ratio, kind, now) ? [] : [ratio]
75    })
76    .sort((a, b) => a.resetsAt - b.resetsAt)
77}
78
79function median(values: readonly number[]): number | null {
80  const sorted = [...values].sort((a, b) => a - b)
81  const middle = Math.floor(sorted.length / 2)
82  const upper = sorted[middle]
83  if (upper === undefined) return null
84  if (sorted.length % 2 === 1) return upper
85
86  const lower = sorted[middle - 1] ?? upper
87
88  return (lower + upper) / 2
89}
90
91export function detectQuotaChange(ratios: readonly WindowRatio[], currentKey: string): QuotaChange | null {
92  const current = ratios.find(ratio => ratio.windowKey === currentKey)
93  if (current?.usdPerPoint == null) return null
94
95  const closed = ratios
96    .filter(ratio => ratio.resetsAt < current.resetsAt)
97    .flatMap(ratio => (ratio.usdPerPoint === null ? [] : [ratio.usdPerPoint]))
98    .reverse()
99  if (closed.length < MIN_CLOSED_WINDOWS) return null
100
101  const [previous, ...older] = closed
102  const baseline = median(older)
103  if (previous === undefined || baseline === null || baseline <= 0) return null
104
105  const currentDeviation = current.usdPerPoint / baseline - 1
106  const previousDeviation = previous / baseline - 1
107  const percent = Math.round(Math.abs(currentDeviation) * 100)
108
109  if (currentDeviation > CHANGE_THRESHOLD && previousDeviation > CHANGE_THRESHOLD) return { direction: 'up', percent }
110  if (currentDeviation < -CHANGE_THRESHOLD && previousDeviation < -CHANGE_THRESHOLD) return { direction: 'down', percent }
111
112  return null
113}
114
115function retainedSince(now: number): number {
116  return Math.floor(now / HOUR_MS) - RETENTION_HOURS
117}
118
119function recentHours(hours: HourlyCosts, now: number): Record<string, number> {
120  const oldest = retainedSince(now)
121
122  return Object.fromEntries(Object.entries(hours).filter(([hour]) => Number(hour) >= oldest))
123}
124
125export function recordCost(ledger: BurnrateCostLedger | null, usd: number, now: number): BurnrateCostLedger {
126  const delta = Math.max(0, usd - (ledger?.lastUsd ?? 0))
127  const hour = String(Math.floor(now / HOUR_MS))
128  const hours = recentHours(ledger?.hours ?? {}, now)
129  if (delta > 0) hours[hour] = (hours[hour] ?? 0) + delta
130
131  return { lastUsd: usd, hours }
132}
133
134export function parseLedger(text: string): BurnrateCostLedger | null {
135  const parsed: unknown = (() => {
136    try {
137      return JSON.parse(text)
138    } catch {
139      return null
140    }
141  })()
142  if (typeof parsed !== 'object' || parsed === null || !('lastUsd' in parsed) || !('hours' in parsed)) return null
143
144  const { lastUsd, hours } = parsed
145  if (typeof lastUsd !== 'number' || typeof hours !== 'object' || hours === null) return null
146
147  const valid = Object.entries(hours).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
148  const label = 'label' in parsed && typeof parsed.label === 'string' ? { label: parsed.label } : {}
149
150  return { lastUsd, hours: Object.fromEntries(valid), ...label }
151}
152
153export function mergeCosts(ledgers: readonly Pick<BurnrateCostLedger, 'hours'>[], now: number): Record<string, number> {
154  const merged: Record<string, number> = {}
155  for (const ledger of ledgers) {
156    for (const [hour, usd] of Object.entries(recentHours(ledger.hours, now))) merged[hour] = (merged[hour] ?? 0) + usd
157  }
158
159  return merged
160}
161
162export function quotaUsdPerPoint(ratios: readonly WindowRatio[], currentKey: string): number | null {
163  const current = ratios.find(ratio => ratio.windowKey === currentKey)
164  if (current === undefined) return null
165  if (current.usdPerPoint !== null) return current.usdPerPoint
166
167  const latestClosed = ratios.filter(ratio => ratio.resetsAt < current.resetsAt && ratio.usdPerPoint !== null).at(-1)
168
169  return latestClosed?.usdPerPoint ?? null
170}
171
hooks/report.ts 25 lines
1import type { BurnrateHistory } from '../types'
2import { formatDay, formatPct } from './format'
3import { closedPeaks, dailyUsage, WEEK_MS, windowKey, type DayUsage } from './pace'
4
5export type WeekReport = { key: string; peak: number; busiest: DayUsage | null }
6
7export function lastWeekReport(history: BurnrateHistory, resetsAt: number, now: number): WeekReport | null {
8  const last = closedPeaks(history, 'seven_day', resetsAt, now)[0]
9  if (last === undefined || now - last.resetsAt >= WEEK_MS) return null
10
11  const key = windowKey('seven_day', last.resetsAt)
12  const busiest = dailyUsage(history, key, last.peak).reduce<DayUsage | null>(
13    (top, day) => (top === null || day.used > top.used ? day : top),
14    null,
15  )
16
17  return { key, peak: last.peak, busiest }
18}
19
20export function reportText({ peak, busiest }: WeekReport): string {
21  const busiestText = busiest === null ? '' : `, busiest ${formatDay(busiest.date)} at ${formatPct(busiest.used)}`
22
23  return `burnrate · last week peaked ${formatPct(peak)}${busiestText}`
24}
25
hooks/format.ts 42 lines
1export function formatDuration(seconds: number): string {
2  const minutes = Math.floor(seconds / 60)
3  const hours = Math.floor(minutes / 60)
4  const days = Math.floor(hours / 24)
5
6  if (days > 0) return `${days}d ${hours % 24}h`
7  if (hours > 0) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
8
9  return `${minutes}m`
10}
11
12const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
13
14export function formatClock(ms: number, withWeekday: boolean): string {
15  const date = new Date(ms)
16  const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
17  if (!withWeekday) return time
18
19  return `${WEEKDAYS[date.getDay()] ?? ''} ${time}`
20}
21
22export function formatDay(date: string): string {
23  const [year, month, day] = date.split('-').map(Number)
24  if (year === undefined || month === undefined || day === undefined) return date
25
26  const weekday = WEEKDAYS[new Date(year, month - 1, day).getDay()] ?? ''
27
28  return `${weekday} ${String(day).padStart(2, '0')}`
29}
30
31export function formatPct(value: number): string {
32  if (value >= 10) return `${Math.round(value)}%`
33
34  return `${Math.round(value * 10) / 10}%`
35}
36
37export function formatUsd(usd: number): string {
38  if (usd < 10) return `$${usd.toFixed(2)}`
39
40  return `$${Math.round(usd)}`
41}
42
hooks/sessions.ts 30 lines
1import type { BurnrateSessionCost } from '../types'
2import { HOUR_MS } from './pace'
3
4type ProjectSpend = { label: string; usd: number }
5
6export type WeekSpend = { sessionUsd: number; projects: ProjectSpend[] }
7
8const TOP_PROJECTS = 5
9
10function usdSince(hours: Readonly<Record<string, number>>, sinceHour: number): number {
11  return Object.entries(hours).reduce((sum, [hour, usd]) => (Number(hour) >= sinceHour ? sum + usd : sum), 0)
12}
13
14export function weekSpend(sessions: readonly BurnrateSessionCost[], since: number, currentId: string): WeekSpend {
15  const sinceHour = Math.floor(since / HOUR_MS)
16  const byLabel = new Map<string, number>()
17  for (const session of sessions) {
18    byLabel.set(session.label, (byLabel.get(session.label) ?? 0) + usdSince(session.hours, sinceHour))
19  }
20
21  const current = sessions.find(session => session.id === currentId)
22  const projects = [...byLabel]
23    .map(([label, usd]) => ({ label, usd }))
24    .filter(project => project.usd > 0)
25    .sort((a, b) => b.usd - a.usd)
26    .slice(0, TOP_PROJECTS)
27
28  return { sessionUsd: usdSince(current?.hours ?? {}, sinceHour), projects }
29}
30
hooks/outlook.ts 46 lines
1export type TracePoint = { elapsedSeconds: number; pct: number }
2
3export type PastWindow = { peak: number; before: TracePoint | null; after: TracePoint | null }
4
5export type Outlook = { typical: number; remaining: number | null; traced: number }
6
7type AgedValue = { value: number; age: number }
8
9const MIN_TRACED_WINDOWS = 2
10const RECENCY_HALF_LIFE_WINDOWS = 2
11
12function weightedMean(values: readonly AgedValue[]): number | null {
13  if (values.length === 0) return null
14
15  const weights = values.map(({ age }) => 0.5 ** (age / RECENCY_HALF_LIFE_WINDOWS))
16  const total = weights.reduce((sum, weight) => sum + weight, 0)
17  const weighted = values.reduce((sum, { value }, index) => sum + value * (weights[index] ?? 0), 0)
18
19  return weighted / total
20}
21
22function pctAtElapsed(past: PastWindow, elapsedSeconds: number, windowSeconds: number): number | null {
23  if (past.before === null && past.after === null) return null
24
25  const from = past.before ?? { elapsedSeconds: 0, pct: 0 }
26  const to = past.after ?? { elapsedSeconds: windowSeconds, pct: past.peak }
27  const span = to.elapsedSeconds - from.elapsedSeconds
28  if (span <= 0) return from.pct
29
30  return from.pct + ((to.pct - from.pct) * (elapsedSeconds - from.elapsedSeconds)) / span
31}
32
33export function outlookOf(pastWindows: readonly PastWindow[], elapsedSeconds: number, windowSeconds: number): Outlook | null {
34  const typical = weightedMean(pastWindows.map((past, age) => ({ value: past.peak, age })))
35  if (typical === null) return null
36
37  const remaining = pastWindows.flatMap((past, age) => {
38    const pctThen = pctAtElapsed(past, elapsedSeconds, windowSeconds)
39
40    return pctThen === null ? [] : [{ value: Math.max(0, past.peak - pctThen), age }]
41  })
42  const isTraced = remaining.length >= MIN_TRACED_WINDOWS
43
44  return { typical, remaining: isTraced ? weightedMean(remaining) : null, traced: isTraced ? remaining.length : 0 }
45}
46
types/index.d.ts 61 lines
1export type BurnrateReading = {
2  kind: string
3  percentUsed: number
4  resetsAt?: string
5}
6
7export type BurnratePeak = {
8  kind: string
9  resetsAt: number
10  peak: number
11}
12
13export type BurnrateDayStart = {
14  windowKey: string
15  date: string
16  pct: number
17  seenAt: number
18}
19
20export type BurnrateHourSample = {
21  windowKey: string
22  hour: number
23  pct: number
24}
25
26export type BurnrateHistory = {
27  peaks: Record<string, BurnratePeak>
28  dayStarts: Record<string, BurnrateDayStart>
29  hours: Record<string, BurnrateHourSample>
30}
31
32export type BurnrateCostLedger = {
33  lastUsd: number
34  hours: Record<string, number>
35  label?: string
36}
37
38export type BurnrateSessionCost = {
39  id: string
40  label: string
41  hours: Record<string, number>
42}
43
44export type BurnrateAlertState = Record<string, { verdict: string; notified: boolean }>
45
46export type BurnrateSnapshot = {
47  readings: BurnrateReading[]
48  history: BurnrateHistory
49  costs: Record<string, number>
50  sessions: BurnrateSessionCost[]
51  sessionId: string
52  model: string
53  now: number
54}
55
56declare module 'claude-code' {
57  interface PluginState {
58    burnrate: { snapshot: BurnrateSnapshot }
59  }
60}
61