Session and machine-wide token usage, plan limit meters with reset countdowns, and an estimate of how long each limit lasts, fitted to your own history.

A Claude Code mod that shows how much of your plan's usage limits you have used, when each limit resets, and how long the rest is likely to last. It also counts the current session's tokens and sums every transcript on your machine into daily, per-project and per-model views, priced at API list prices.
Band above the prompt: the session's cost and context use, and each limit window (5-hour, weekly) with its percent and, when it will fill before its reset, how soon. It has buttons to open the pane or hide the band.
Pane (/token-almanac, or the band's button), with two views:
| View | Contents |
|---|---|
| This session | Limit rings, cost, tokens, context, turns; per-turn chart (subagent turns folded into the turn that started them); under Show breakdown, token makeup by API-equivalent cost and the per-model split |
| This machine | Limit rings, each with the API-equivalent dollars a full window holds and what is left; your plan, picked from a list; 30-day or all-time totals; daily chart by model; under Show breakdown, top models and projects, a capacity card per window (whether it fills before the reset at your usual hourly pace, confidence, past limit hits on your plan on a dollar scale) and the estimate details as interval plots, with any earlier plan inferred from the history |
Each ring shows the share used, a paler arc for where the window is headed by its reset, and a tick for how far through the window the clock is. In the terminal the same figures are written as text, with meters in place of the rings and the capacity table under them.
The UI is in English or Chinese, picked from the language option, then Claude Code's language setting, then LC_ALL / LANG, then the system locale. It renders in the terminal, the desktop app, VS Code and mobile.
Requires Claude Code 2.1.287 or later (mods support). The machine view needs Node.js 18 or later on PATH, or set its path in the nodePath option.
In a Claude Code session, add this repository as a marketplace, then install from it:
/plugin marketplace add CookPiu/token-almanac
/plugin install token-almanac@token-almanac
Or from a shell, in one step:
claude plugin install token-almanac --marketplace CookPiu/token-almanac
Installed at user scope (the default), it also loads in sessions the desktop app starts. Run /plugin marketplace update token-almanac to pick up new versions.
Set them in /config under the plugin:
| Option | Default | Meaning |
|---|---|---|
language | auto | auto, en or zh |
nodePath | node | The Node.js used to read transcript history |
motion | on | on lets meters and charts glide to new values and a spinner turn while scanning; off draws them still |
To price models at other rates (Bedrock, Vertex, a gateway) or to add models the built-in table lacks, write <config>/token-almanac/prices.json, with prices in USD per million tokens as [input, output, cache read, cache write 5m, cache write 1h], keyed by model id or id prefix:
{ "claude-opus-5-5": [4, 20, 0.2, 5, 8] }
<config> is CLAUDE_CONFIG_DIR, or ~/.claude.
Claude Code reports each limit only as a percent and a reset time. To turn that into "how much is left", the mod sums what this machine spent in each window, by model and token kind, at API list prices, and fits how much of a window each API dollar takes:
rise × plan multiple ≈ Σ model factor × Σ token-kind factor × API spend, solved by Levenberg–Marquardt in log parameters with weak priors;The pane shows the confidence and the fitted factors. Use from claude.ai or other devices counts in the official percent but not in this machine's transcripts: pieces it lifts far are set aside, a little of it still makes estimates less certain. How Anthropic counts limits is not published; these figures are inferences.
Everything stays on your machine. Nothing is sent anywhere: the mod makes no network requests, and the program it runs makes none either.
<config>/projects/ to count tokens; message text is parsed only to find turn boundaries and is never stored or sent.language setting and the CLAUDE_CONFIG_DIR, HOME, USERPROFILE, LC_ALL and LANG environment variables.<config>/token-almanac/cache.json (per-file read offsets and 10-minute sums, so later scans read only new lines), which only token-almanac reads; it is not a setting, instruction or script for Claude Code or any other tool. Limit readings, the plans you pick and a compact history of windows that have reset (a reading every 5 points with each model's tokens so far) go in the plugin's own store.node, or the nodePath option) on the bundled scan/scan.mjs, because transcripts are larger than the mod environment can read. The two command lines arenode <plugin>/scan/scan.mjs session <this session's transcript>, when a session starts, to count what ran before the mod loaded;node <plugin>/scan/scan.mjs global <config>/projects <config>/token-almanac/cache.json, when the machine view opens or refreshes, with the window start times and limit readings on stdin.The scanner reads the transcripts and its own cache, writes only cache.json, prints token sums as JSON on stdout for the mod, and makes no network requests. Its source is in scan/scan.mjs.
claude plugin validate .
claude plugin test .
node --test scan/scan.check.mjs
For a working copy, start Claude Code with claude --plugin-dir <this folder>.
MIT
hooks/register.tsx 2372 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Fit, GlobalScan, GlobalView, InferredPlan, Machine, Observation, PlanChoice, Reading, Samples, ScanStatus, Snapshot, Tokens, TurnRow, Vec, Window } from '../types'
5
6import { MESSAGES, localeOf } from './i18n'
7import type { Locale, Messages } from './i18n'
8
9const PANE = 'token-almanac'
10// The plan in force when none was ever picked.
11const CURRENT = 'current'
12
13// The words drawn, in the language resolved at load (English until then).
14let t: Messages = MESSAGES.en
15export function setLocale(locale: Locale): void {
16 t = MESSAGES[locale]
17}
18// The Node.js the scanner runs on, from the `nodePath` option.
19let nodeBin = 'node'
20const ZERO: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
21const MINUTE = 60_000
22const HOUR = 60 * MINUTE
23// The forecast reads the growth over this much recent time, and needs at least the minimum.
24const RATE_LOOKBACK_MS = 90 * MINUTE
25const RATE_MIN_SPAN_MS = 10 * MINUTE
26
27const since = atom({ plugin: 'token-almanac', key: 'since' } as const, 0)
28const totals = atom({ plugin: 'token-almanac', key: 'totals' } as const, ZERO)
29const byModel = atom({ plugin: 'token-almanac', key: 'byModel' } as const, {} as Record<string, Tokens>)
30const turns = atom({ plugin: 'token-almanac', key: 'turns' } as const, [] as TurnRow[])
31const snapshot = atom({ plugin: 'token-almanac', key: 'snapshot' } as const, null as Snapshot | null)
32const samples = atom({ plugin: 'token-almanac', key: 'samples' } as const, {} as Record<string, Samples>)
33const isBandHidden = atom({ plugin: 'token-almanac', key: 'isBandHidden' } as const, false)
34const view = atom({ plugin: 'token-almanac', key: 'view' } as const, 'session' as 'session' | 'global')
35const range = atom({ plugin: 'token-almanac', key: 'range' } as const, '30d' as '30d' | 'all')
36const machine = atom({ plugin: 'token-almanac', key: 'machine' } as const, null as Machine | null)
37const scanStatus = atom({ plugin: 'token-almanac', key: 'scanStatus' } as const, { state: 'idle' } as ScanStatus)
38const planChoices = atom({ plugin: 'token-almanac', key: 'planChoices' } as const, [] as PlanChoice[])
39const details = atom({ plugin: 'token-almanac', key: 'details' } as const, false)
40
41// Kinds of token in the pane's palette: clay for output, its soft tint for cache writes, sand and
42// stone for the reads and plain input that carry least weight.
43const KIND_COLOR = { input: '#A39E93', output: '#D97757', cacheRead: '#D6CCBE', cacheWrite: '#E9A88C' } as const
44
45// ---- Pure helpers ---------------------------------------------------------
46
47function add(a: Tokens, b: Tokens): Tokens {
48 return { input: a.input + b.input, output: a.output + b.output, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite }
49}
50
51function sum(t: Tokens): number {
52 return t.input + t.output + t.cacheRead + t.cacheWrite
53}
54
55export function fmtTokens(n: number): string {
56 if (n < 1000) return String(n)
57 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
58 if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`
59 return `${(n / 1_000_000_000).toFixed(2)}B`
60}
61
62export function fmtDuration(ms: number): string {
63 const minutes = Math.max(0, Math.round(ms / MINUTE))
64 const d = Math.floor(minutes / 1440)
65 const hrs = Math.floor((minutes % 1440) / 60)
66 const m = minutes % 60
67 if (d > 0) return `${d}d${hrs}h`
68 if (hrs > 0) return `${hrs}h${m}m`
69 return `${m}m`
70}
71
72function windowLabel(kind: string): string {
73 return t.windows[kind] ?? kind.replace(/_/g, ' ')
74}
75
76function planLabel(plan: string): string {
77 return t.plans[plan] ?? plan.replace(/_/g, ' ')
78}
79
80// True once a reply has come back with no limit readings: an account billed by the API (or a
81// gateway that reports none), not one still waiting for its first reading.
82function hasNoLimits(snap: Snapshot | null): boolean {
83 return snap !== null && snap.windows.length === 0 && snap.contextTokens !== undefined
84}
85
86function levelColor(percent: number): string {
87 return percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'claude'
88}
89
90// `etaMs` is set only when the window would be full before it resets; `state` says which of the
91// outlooks applies, for the pane to word.
92export type Forecast = { ratePerHour: number | null; etaMs: number | null; state: 'full' | 'unknown' | 'flat' | 'safe' | 'eta' }
93
94// How fast a window fills, from its readings over the last 90 minutes up to now, and when it
95// would be full at that pace. Measuring up to now (not the last reading) lets idle time slow it.
96export function forecast(points: readonly [number, number][], percent: number, now: number, resetsAt?: string): Forecast {
97 if (percent >= 100) return { ratePerHour: null, etaMs: null, state: 'full' }
98 const recent = points.filter(([t]) => t >= now - RATE_LOOKBACK_MS)
99 const base = recent[0] ?? points[points.length - 1]
100 if (base === undefined || now - base[0] < RATE_MIN_SPAN_MS) {
101 return { ratePerHour: null, etaMs: null, state: 'unknown' }
102 }
103 const ratePerHour = ((percent - base[1]) / (now - base[0])) * HOUR
104 if (ratePerHour <= 0) return { ratePerHour: 0, etaMs: null, state: 'flat' }
105 const etaMs = ((100 - percent) / ratePerHour) * HOUR
106 const resetMs = resetsAt ? Date.parse(resetsAt) - now : Number.NaN
107 if (!Number.isNaN(resetMs) && etaMs >= resetMs) {
108 return { ratePerHour, etaMs: null, state: 'safe' }
109 }
110 return { ratePerHour, etaMs, state: 'eta' }
111}
112
113function resetText(resetsAt: string | undefined, now: number): string {
114 if (!resetsAt) return ''
115 const ms = Date.parse(resetsAt) - now
116 return Number.isNaN(ms) ? '' : ms <= 0 ? t.resetting : t.resetsIn(fmtDuration(ms))
117}
118
119function escapeXml(text: string): string {
120 return text.replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c] ?? c)
121}
122
123function toSnapshot(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, now: number): Snapshot {
124 return {
125 at: now,
126 contextTokens: u.context.tokens,
127 contextWindow: u.context.window,
128 contextPercent: u.context.percent,
129 costUsd: u.cost?.usd,
130 windows: u.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
131 }
132}
133
134function withReading(all: Record<string, Samples>, w: Window, now: number): Record<string, Samples> {
135 const current = all[w.kind]
136 const series: Samples = current && current.resetsAt === w.resetsAt ? current : { resetsAt: w.resetsAt, points: [] }
137 const last = series.points[series.points.length - 1]
138 if (last && last[1] === w.percentUsed && now - last[0] < 10 * MINUTE) {
139 return all
140 }
141 return { ...all, [w.kind]: { ...series, points: [...series.points, [now, w.percentUsed] as [number, number]].slice(-300) } }
142}
143
144// What `scan/scan.mjs session` prints: the session's counts as its transcript records them.
145export type Parsed = { totals: Tokens; byModel: Record<string, Tokens>; turns: TurnRow[] }
146
147// ---- Prices and the global view --------------------------------------------
148
149// USD per million tokens: [input, output, cache read, cache write 5m, cache write 1h], from
150// https://platform.claude.com/docs/en/about-claude/pricing as read on 2026-10-10. Haiku 5.5's
151// higher rate for prompts over 100k tokens is not applied.
152const PRICES: Record<string, Vec> = {
153 'claude-fable-5-1': [10, 50, 0.25, 12.5, 20],
154 'claude-mythos-5-1': [10, 50, 0.25, 12.5, 20],
155 'claude-fable-5': [10, 50, 1, 12.5, 20],
156 'claude-mythos-5': [10, 50, 1, 12.5, 20],
157 'claude-opus-5-5': [4, 20, 0.2, 5, 8],
158 'claude-opus-5': [5, 25, 0.5, 6.25, 10],
159 'claude-opus-4-8': [5, 25, 0.5, 6.25, 10],
160 'claude-opus-4-7': [5, 25, 0.5, 6.25, 10],
161 'claude-opus-4-6': [5, 25, 0.5, 6.25, 10],
162 'claude-sonnet-5-5': [2, 10, 0.1, 2.5, 4],
163 'claude-sonnet-5': [2, 10, 0.2, 2.5, 4],
164 'claude-sonnet-4-6': [3, 15, 0.3, 3.75, 6],
165 'claude-haiku-5-5': [0.1, 0.5, 0.01, 0.125, 0.2],
166 'claude-haiku-4-5': [1, 5, 0.1, 1.25, 2],
167}
168
169// A model's prices by its id, or by the longest id it starts with (a dated or suffixed id).
170// Prices from <config>/token-almanac/prices.json, over the built-in ones: for another provider's
171// rates (Bedrock, Vertex), a model this table lacks, or a price change before an update.
172let priceOverrides: Record<string, Vec> = {}
173
174export function setPriceOverrides(prices: Record<string, unknown>): void {
175 priceOverrides = Object.fromEntries(
176 Object.entries(prices).filter((e): e is [string, Vec] => Array.isArray(e[1]) && e[1].length === 5 && e[1].every(x => typeof x === 'number')),
177 )
178}
179
180export function priceOf(model: string): Vec | null {
181 const table = { ...PRICES, ...priceOverrides }
182 const exact = table[model]
183 if (exact) return exact
184 const key = Object.keys(table)
185 .filter(k => model.startsWith(k))
186 .sort((a, b) => b.length - a.length)[0]
187 return key === undefined ? null : table[key]!
188}
189
190export function costOf(model: string, v: readonly number[]): number {
191 const p = priceOf(model)
192 if (p === null) return 0
193 let total = 0
194 for (let i = 0; i < 5; i += 1) total += (v[i] ?? 0) * p[i]!
195 return total / 1_000_000
196}
197
198const vsum = (v: readonly number[]) => v.reduce((a, b) => a + b, 0)
199const pad2 = (n: number) => String(n).padStart(2, '0')
200
201function dayValue(day: string): number {
202 const [y, m, d] = day.split('-').map(Number)
203 return Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1)
204}
205
206function dayShift(day: string, delta: number): string {
207 const t = new Date(dayValue(day) + delta * 86_400_000)
208 return `${t.getUTCFullYear()}-${pad2(t.getUTCMonth() + 1)}-${pad2(t.getUTCDate())}`
209}
210
211
212// The scan over the chosen range: cost per day, project and model, and the average cost of each
213// local hour of the day, the day's shape the forecast walks forward.
214export function globalView(scan: GlobalScan, range: '30d' | 'all'): GlobalView {
215 const start = range === '30d' ? dayShift(scan.today, -29) : ''
216 const rows = scan.rows.filter(r => r[0] >= start)
217 const firstDay = rows.reduce<string | null>((a, r) => (a === null || r[0] < a ? r[0] : a), null)
218 const days = firstDay === null ? 0 : Math.round((dayValue(scan.today) - dayValue(firstDay)) / 86_400_000) + 1
219
220 const perDay = new Map<string, Record<string, number>>()
221 const projects = new Map<string, { cost: number; tokens: number }>()
222 const models = new Map<string, { cost: number; tokens: number }>()
223 const hours = new Array<number>(24).fill(0)
224 const hoursByModel: Record<string, number[][]> = {}
225 const unpriced = new Set<string>()
226 let totalCost = 0
227 let totalTokens = 0
228
229 for (const [day, hour, project, model, ...v] of rows) {
230 const cost = costOf(model, v)
231 const tokens = vsum(v)
232 if (priceOf(model) === null && tokens > 0) unpriced.add(model)
233 totalCost += cost
234 totalTokens += tokens
235 const d = perDay.get(day) ?? {}
236 d[model] = (d[model] ?? 0) + cost
237 perDay.set(day, d)
238 const p = projects.get(project) ?? { cost: 0, tokens: 0 }
239 projects.set(project, { cost: p.cost + cost, tokens: p.tokens + tokens })
240 const m = models.get(model) ?? { cost: 0, tokens: 0 }
241 models.set(model, { cost: m.cost + cost, tokens: m.tokens + tokens })
242 hours[hour] = (hours[hour] ?? 0) + cost
243 const hm = (hoursByModel[model] ??= Array.from({ length: 24 }, () => [0, 0, 0, 0]))
244 spendByKind(model, v).forEach((x, k) => (hm[hour]![k]! += x))
245 }
246
247 const daily: GlobalView['daily'] = []
248 for (let i = 0; i < days; i += 1) {
249 const day = dayShift(firstDay ?? scan.today, i)
250 daily.push({ day, byModel: perDay.get(day) ?? {} })
251 }
252 const byCost = <T extends { cost: number; tokens: number }>(a: T, b: T) => b.cost - a.cost || b.tokens - a.tokens
253 return {
254 days,
255 totalCost,
256 totalTokens,
257 daily,
258 byProject: [...projects].map(([project, x]) => ({ project, ...x })).sort(byCost),
259 byModel: [...models].map(([model, x]) => ({ model, ...x })).filter(x => x.tokens > 0).sort(byCost),
260 hourly: hours.map(c => (days > 0 ? c / days : 0)),
261 hourlyByModel: Object.fromEntries(Object.entries(hoursByModel).map(([m, hs]) => [m, hs.map(kinds => kinds.map(x => (days > 0 ? x / days : 0)))])),
262 unpriced: [...unpriced],
263 }
264}
265
266// The length of a limit window, to find where the current one began.
267function windowLength(kind: string): number | null {
268 if (kind === 'five_hour') return 5 * HOUR
269 if (kind.startsWith('seven_day')) return 7 * 24 * HOUR
270 return null
271}
272
273export const fmtUsd = (n: number) => (n >= 100 ? `$${n.toFixed(0)}` : `$${n.toFixed(2)}`)
274// For estimates, where cents would claim a precision they do not have.
275const roughUsd = (n: number) => (n >= 10 ? `$${n.toFixed(0)}` : `$${n.toFixed(1)}`)
276
277// ---- The fit ----------------------------------------------------------------
278
279// Each plan's window as a multiple of Pro's, as sold, where it is sold that way. The fit starts
280// from these and moves them as far as the data says; the sold multiples are not taken as exact.
281// A plan with none (Team, Enterprise, a tier not known here) starts level with the reference.
282const NOMINAL: Record<string, number> = { pro: 1, max5x: 5, max20x: 20 }
283// The plans the pane offers to pick.
284const PLAN_CHOICES = ['pro', 'max5x', 'max20x', 'team', 'team_premium', 'enterprise'] as const
285// The pick that says the plan is not known.
286const UNKNOWN = 'unknown'
287// How much a start with no sold multiple behind it counts: next to nothing.
288const WEAK_PRIOR = 0.05
289
290// One plan's window against another's, as sold; null when either is not sold as a multiple.
291function nominalRatio(plan: string, against: string): number | null {
292 const a = NOMINAL[plan]
293 const b = NOMINAL[against]
294 return a === undefined || b === undefined ? null : a / b
295}
296
297// A plan's window against the fit's reference plan: fitted, else as sold, else unknown.
298function planScale(fit: Fit, plan: string): number | null {
299 return fit.s[plan] ?? nominalRatio(plan, fit.refPlan)
300}
301// How many windows' worth of evidence the starting values (API prices, sold multiples) count as.
302const PRIOR_WINDOWS = 0.5
303// The pull of the kinds' weights toward each other, far weaker: a kind the pieces barely tell apart
304// (cache reads, a small share of every piece's weighted spend) was dragged toward the rest, about a
305// third too heavy on test data drawn from known weights; at this pull it is within a tenth, and its
306// band still widens to "unclear" where the pieces cannot say.
307const KIND_PRIOR = 0.05
308// How many windows' worth a longer window kind's fit leans on the five-hour fit's way of weighing
309// kinds and models, when that fit is at least of medium confidence: enough to steady a kind with
310// a few pieces, little enough that a kind with its own evidence goes its own way.
311const BORROW_WINDOWS = 2
312// How many of its own spreads a piece may rise past what its spend says before it is taken for
313// use this machine did not see and set aside. On test data with a few points of such use in three
314// windows in eleven, weighing those pieces down (Huber) left shares about 5% heavy; setting them
315// aside past 2 spreads leaves about 1.5%, with no piece of the clean data set aside, where past
316// 1.5 spreads starts setting clean pieces aside too.
317const UNSEEN_AT = 2
318// Below this many pieces there is no rest to tell a piece apart from (two limit hits a third
319// apart say nothing of which one had help): such fits only weigh down.
320const UNSEEN_MIN_PIECES = 6
321
322// Gaussian elimination with partial pivoting; null when the system is singular.
323function solve(a: number[][], b: number[]): number[] | null {
324 const n = b.length
325 const m = a.map((row, i) => [...row, b[i]!])
326 for (let col = 0; col < n; col += 1) {
327 let pivot = col
328 for (let r = col + 1; r < n; r += 1) if (Math.abs(m[r]![col]!) > Math.abs(m[pivot]![col]!)) pivot = r
329 if (Math.abs(m[pivot]![col]!) < 1e-300) return null
330 ;[m[col], m[pivot]] = [m[pivot]!, m[col]!]
331 for (let r = 0; r < n; r += 1) {
332 if (r === col) continue
333 const f = m[r]![col]! / m[col]![col]!
334 for (let c = col; c <= n; c += 1) m[r]![c]! -= f * m[col]![c]!
335 }
336 }
337 return m.map((row, i) => row[n]! / row[i]!)
338}
339
340function median(xs: number[]): number | null {
341 if (xs.length === 0) return null
342 const s = [...xs].sort((a, b) => a - b)
343 const mid = Math.floor(s.length / 2)
344 return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
345}
346
347// The four kinds of token the fit weighs apart: the two cache writes share one weight.
348export const TOKEN_KINDS = ['input', 'output', 'cacheRead', 'cacheWrite'] as const
349
350// A model's spend by kind of token, in API dollars: [input, output, cache read, cache write].
351export function spendByKind(model: string, v: readonly number[]): number[] {
352 const p = priceOf(model)
353 if (p === null) return [0, 0, 0, 0]
354 return [(v[0] ?? 0) * p[0], (v[1] ?? 0) * p[1], (v[2] ?? 0) * p[2], (v[3] ?? 0) * p[3] + (v[4] ?? 0) * p[4]].map(x => x / 1_000_000)
355}
356
357type Segment = { window: number; plan: string; dy: number; c: Record<string, number[]>; hit: boolean }
358
359// The pieces the fit learns from. Within a window, from its start (0%, nothing spent) through
360// its readings, a piece closes each time the percent has risen 5 points or more: what that piece
361// spent, by model and kind, against how far the percent rose. A limit hit is the whole window
362// to 100%. Pieces let the different ways a window was used (a long session's cache reads, many
363// sessions' cache writes) each speak, where a window's running total would blur them.
364export function segmentsOf(kind: string, all: readonly Observation[]): Segment[] {
365 const byWindow = new Map<number, Observation[]>()
366 for (const o of all) {
367 if (o.kind !== kind) continue
368 byWindow.set(o.resetsAt, [...(byWindow.get(o.resetsAt) ?? []), o])
369 }
370 const kindsOf = (byModel: Record<string, Vec>) =>
371 Object.fromEntries(Object.entries(byModel).map(([m, v]) => [m, spendByKind(m, v)]).filter(([, c]) => (c as number[]).some(x => x > 0)))
372 const minus = (a: Record<string, number[]>, b: Record<string, number[]>) =>
373 Object.fromEntries(Object.entries(a).map(([m, c]) => [m, c.map((x, k) => Math.max(0, x - (b[m]?.[k] ?? 0)))]))
374 const out: Segment[] = []
375 for (const [window, list] of byWindow) {
376 for (const obs of list.filter(o => o.hit)) out.push({ window, plan: CURRENT, dy: 1, c: kindsOf(obs.byModel), hit: true })
377 let anchor = { y: 0, c: {} as Record<string, number[]> }
378 for (const o of list.filter(o => !o.hit).sort((a, b) => a.t - b.t)) {
379 if (o.y - anchor.y < 0.05) continue
380 const c = kindsOf(o.byModel)
381 out.push({ window, plan: CURRENT, dy: o.y - anchor.y, c: minus(c, anchor.c), hit: false })
382 anchor = { y: o.y, c }
383 }
384 }
385 return out.filter(s => Object.values(s.c).some(c => c.some(x => x > 0)))
386}
387
388// One window's observations cut down to those segmentsOf closes a piece on: each reading 5 points
389// or more past the last one kept, and the window's first hit. The pieces they make are the same.
390export function compactWindow(obs: readonly Observation[]): Observation[] {
391 const kept: Observation[] = []
392 let last = 0
393 for (const o of obs.filter(o => !o.hit).sort((a, b) => a.t - b.t)) {
394 if (o.y - last < 0.05) continue
395 kept.push(o)
396 last = o.y
397 }
398 const hit = obs.filter(o => o.hit).sort((a, b) => a.t - b.t)[0]
399 return hit === undefined ? kept : [...kept, hit]
400}
401
402// Fits one window kind on its pieces: rise × s[plan] ≈ Σ f[model] × Σ g[kind] × spend[model][kind].
403// g (one per kind of token, shared by every model) is the share of a Pro window one API dollar of
404// that kind of the reference model takes, on the reference plan (the one picked now, else the one
405// with the most windows);
406// f scales another model against the reference model (1); s is another plan's window against the
407// reference plan's (1). All are fitted in logs, together (Levenberg–Marquardt). A
408// piece's miss is told against what it can be off by: the limit reports whole percents, so each
409// end of a piece may be half a point off (ROUNDING), and use the transcripts do not see adds a
410// share of the rise (SPREAD). Each parameter starts where API prices and sold multiples put it
411// (the kinds' g equal, f at 1, s at the sold ratio where both plans have one, else at 1), that
412// start weighing as much as half a piece (PRIOR_WINDOWS, or WEAK_PRIOR with no sold ratio; the
413// kinds' g far less, KIND_PRIOR), so the
414// pieces move it as far as they agree to. Once the rest have settled, a piece that rose more than
415// UNSEEN_AT of its own spreads past what its spend says is set aside: use this machine did not see
416// (another device, the web app) only ever adds to a rise. A piece off the other way by more than
417// 2.5 spreads, which no unseen use explains, is weighed down (Huber).
418function fitSegments(kind: string, segs: Segment[], preferRef: string, borrow: Fit | null = null): Fit {
419
420 const spend = new Map<string, number>()
421 for (const sg of segs) for (const [m, c] of Object.entries(sg.c)) spend.set(m, (spend.get(m) ?? 0) + c.reduce((a, b) => a + b, 0))
422 const models = [...spend].sort((a, b) => b[1] - a[1]).map(([m]) => m)
423 const ref = models[0]!
424 const others = models.slice(1)
425 const planWindows: Record<string, number> = {}
426 for (const p of new Set(segs.map(sg => sg.plan))) planWindows[p] = new Set(segs.filter(sg => sg.plan === p).map(sg => sg.window)).size
427 const refPlan = planWindows[preferRef] !== undefined ? preferRef : Object.entries(planWindows).sort((a, b) => b[1] - a[1] || (a[0] === 'pro' ? -1 : 1))[0]![0]
428 const plans = Object.keys(planWindows).filter(p => p !== refPlan)
429 const startScale = (pl: string) => (pl === refPlan ? 1 : (nominalRatio(pl, refPlan) ?? 1))
430 const priorWeight = (pl: string) => (nominalRatio(pl, refPlan) === null ? WEAK_PRIOR : PRIOR_WINDOWS)
431 // Another window kind's fit to start from: its kinds' weights as a shape (logs about their mean),
432 // and its models against this fit's reference model where it has that one. The size of the
433 // window is never lent: it is this kind's own.
434 const lent =
435 borrow === null || !borrow.g.every(x => x > 0)
436 ? null
437 : (() => {
438 const logs = borrow.g.map(Math.log)
439 const mean = logs.reduce((a, b) => a + b, 0) / logs.length
440 const base = borrow.f[ref]
441 const f: Record<string, number> = base === undefined ? {} : Object.fromEntries(Object.entries(borrow.f).map(([m, x]) => [m, Math.log(x / base)]))
442 return { g: logs.map(x => x - mean), f }
443 })()
444
445 // Parameters, in logs: g[0..3], then f of each other model, then s of each other plan.
446 const nG = 4
447 const nF = others.length
448 const n = nG + nF + plans.length
449 const totalOf = (sg: Segment) => Object.values(sg.c).reduce((a, c) => a + c.reduce((x, y) => x + y, 0), 0)
450 const start = (() => {
451 let num = 0
452 let den = 0
453 for (const sg of segs) {
454 const target = sg.dy * startScale(sg.plan)
455 num += totalOf(sg) / target
456 den += (totalOf(sg) / target) ** 2
457 }
458 return Math.log(num / den)
459 })()
460 let p = [...new Array<number>(nG).fill(start), ...new Array<number>(nF).fill(0), ...plans.map(pl => Math.log(startScale(pl)))]
461
462 const unpack = (q: number[]) => ({
463 g: q.slice(0, nG).map(Math.exp),
464 f: { [ref]: 1, ...Object.fromEntries(others.map((m, i) => [m, Math.exp(q[nG + i]!)])) } as Record<string, number>,
465 s: { [refPlan]: 1, ...Object.fromEntries(plans.map((pl, i) => [pl, Math.exp(q[nG + nF + i]!)])) } as Record<string, number>,
466 })
467 // A hit is the whole window, exact at 100%: it counts double, unless the window's readings already
468 // cut it into pieces, which say the same spend again.
469 const read = new Set(segs.filter(sg => !sg.hit).map(sg => sg.window))
470 const base = segs.map(sg => (sg.hit && !read.has(sg.window) ? 2 : 1))
471 let robust = segs.map(() => 1)
472 const ROUNDING = 0.006
473 const SPREAD = 0.1
474
475 // Residuals and their Jacobian: one row per piece (relative miss), then the start's pulls. The
476 // cost of a trial step needs the residuals alone, so the Jacobian is built only when asked for.
477 const NO_J: number[] = []
478 const system = (q: number[], withJ = true) => {
479 const { g, f, s } = unpack(q)
480 const rows: { r: number; j: number[]; w: number }[] = []
481 segs.forEach((sg, i) => {
482 const target = sg.dy * s[sg.plan]!
483 const perKind = [0, 1, 2, 3].map(k => models.reduce((acc, m) => acc + f[m]! * (sg.c[m]?.[k] ?? 0), 0))
484 const pred = perKind.reduce((acc, x, k) => acc + g[k]! * x, 0)
485 // What this piece can be off by, in shares of a window scaled by the plan's multiple.
486 const d = Math.hypot(ROUNDING * s[sg.plan]!, SPREAD * target)
487 if (!withJ) {
488 rows.push({ r: (pred - target) / d, j: NO_J, w: base[i]! * robust[i]! })
489 return
490 }
491 const j = new Array<number>(n).fill(0)
492 perKind.forEach((x, k) => (j[k] = (g[k]! * x) / d))
493 others.forEach((m, r) => (j[nG + r] = (f[m]! * (sg.c[m] ?? [0, 0, 0, 0]).reduce((acc, v, k) => acc + g[k]! * v, 0)) / d))
494 const pi = plans.indexOf(sg.plan)
495 if (pi >= 0) j[nG + nF + pi] = -target / d
496 rows.push({ r: (pred - target) / d, j, w: base[i]! * robust[i]! })
497 })
498 const meanG = q.slice(0, nG).reduce((a, b) => a + b, 0) / nG
499 for (let k = 0; k < nG; k += 1) {
500 const j = new Array<number>(n).fill(0)
501 for (let c = 0; c < nG; c += 1) j[c] = (c === k ? 1 : 0) - 1 / nG
502 rows.push(lent === null ? { r: q[k]! - meanG, j, w: KIND_PRIOR } : { r: q[k]! - meanG - lent.g[k]!, j, w: BORROW_WINDOWS })
503 }
504 for (let i = 0; i < nF; i += 1) {
505 const j = new Array<number>(n).fill(0)
506 j[nG + i] = 1
507 const at = lent?.f[others[i]!]
508 rows.push(at === undefined ? { r: q[nG + i]!, j, w: PRIOR_WINDOWS } : { r: q[nG + i]! - at, j, w: BORROW_WINDOWS })
509 }
510 plans.forEach((pl, i) => {
511 const j = new Array<number>(n).fill(0)
512 j[nG + nF + i] = 1
513 rows.push({ r: q[nG + nF + i]! - Math.log(startScale(pl)), j, w: priorWeight(pl) })
514 })
515 return rows
516 }
517 const sumSquares = (rows: { r: number; w: number }[]) => rows.reduce((acc, row) => acc + row.w * row.r * row.r, 0)
518
519 // Weighs down the pieces the rest cannot explain: three times, each once the fit has settled or
520 // had 30 rounds, whichever comes first, so a fit that settles fast is weighed all the same.
521 let mu = 1e-3
522 let reweighs = 0
523 let since = 0
524 for (let iter = 0; iter < 200; iter += 1) {
525 const rows = system(p)
526 const a = Array.from({ length: n }, () => new Array<number>(n).fill(0))
527 const b = new Array<number>(n).fill(0)
528 for (const { r, j, w } of rows) {
529 for (let x = 0; x < n; x += 1) {
530 b[x]! -= w * j[x]! * r
531 for (let y = 0; y < n; y += 1) a[x]![y]! += w * j[x]! * j[y]!
532 }
533 }
534 for (let x = 0; x < n; x += 1) a[x]![x]! *= 1 + mu
535 const step = solve(a, b)
536 if (step === null) break
537 const next = p.map((v, i) => v + Math.max(-2, Math.min(2, step[i]!)))
538 let isSettled = false
539 if (sumSquares(system(next, false)) < sumSquares(rows)) {
540 p = next
541 mu = Math.max(1e-9, mu / 3)
542 isSettled = Math.max(...step.map(Math.abs)) < 1e-7
543 } else {
544 mu *= 4
545 isSettled = mu > 1e9
546 }
547 since += 1
548 if (isSettled || since >= 30) {
549 if (reweighs === 3) {
550 if (isSettled) break
551 continue
552 }
553 robust = system(p, false)
554 .slice(0, segs.length)
555 .map(row => (row.r < -UNSEEN_AT && segs.length >= UNSEEN_MIN_PIECES ? 0.01 : Math.abs(row.r) <= 2.5 ? 1 : 2.5 / Math.abs(row.r)))
556 reweighs += 1
557 since = 0
558 mu = 1e-3
559 }
560 }
561
562 const { g, f, s } = unpack(p)
563 // The pieces set aside as unseen use, and the rest weighed down.
564 const unseen = robust.filter(w => w === 0.01).length
565
566 // 95% bands for each kind's weight against output's, from the curvature at the fit: wide where
567 // the pieces cannot tell two kinds apart (they always came together in the same proportion).
568 const gBand: [number, number][] = (() => {
569 const rows = system(p)
570 const a = Array.from({ length: n }, () => new Array<number>(n).fill(0))
571 let rss = 0
572 for (const { r, j, w } of rows) {
573 rss += w * r * r
574 for (let x = 0; x < n; x += 1) for (let y = 0; y < n; y += 1) a[x]![y]! += w * j[x]! * j[y]!
575 }
576 const sigma2 = rss / Math.max(1, segs.length - n)
577 const column = (k: number) => solve(a, new Array<number>(n).fill(0).map((_, i) => (i === k ? 1 : 0)))
578 const c1 = column(1)
579 return [0, 1, 2, 3].map(k => {
580 const ck = column(k)
581 const rel = g[k]! / g[1]!
582 if (ck === null || c1 === null) return [0, Infinity] as [number, number]
583 const v = Math.max(0, sigma2 * (ck[k]! + c1[1]! - 2 * ck[1]!))
584 const half = 1.96 * Math.sqrt(v)
585 return [rel * Math.exp(-half), rel * Math.exp(half)] as [number, number]
586 })
587 })()
588
589 const predOf = (sg: Segment) => [0, 1, 2, 3].reduce((acc, k) => acc + g[k]! * models.reduce((a, m) => a + f[m]! * (sg.c[m]?.[k] ?? 0), 0), 0)
590 const misses = segs.map(sg => Math.abs(predOf(sg) / s[sg.plan]! - sg.dy) * 100)
591 const mae = median(misses)
592 const windows = new Set(segs.map(sg => sg.window)).size
593 // How well the pieces pin the two kinds that set a way of use apart: cache reads and writes.
594 const spreadOf = (k: number) => {
595 const [lo, hi] = gBand[k] ?? [0, Infinity]
596 return Number.isFinite(hi) && lo > 0 ? hi / lo : Infinity
597 }
598 const spread = Math.max(spreadOf(2), spreadOf(3))
599 const confidence =
600 segs.length >= 12 && windows >= 3 && (mae ?? 99) <= 3 && spread <= 4 ? 'high' : segs.length >= 5 && spread <= 15 ? 'medium' : 'low'
601 return {
602 kind,
603 windows,
604 segments: segs.filter(sg => !sg.hit).length,
605 hits: segs.filter(sg => sg.hit).length,
606 f,
607 g,
608 gBand,
609 s,
610 refPlan,
611 planWindows,
612 windowPlans: {},
613 latest: null,
614 inferred: [],
615 mae,
616 outliers: robust.filter(r => r < 1).length - unseen,
617 unseen,
618 confidence,
619 ref,
620 lent: lent === null ? null : (borrow?.kind ?? null),
621 }
622}
623
624// The plan a pick puts in force at a moment; null before any pick, or after "not sure".
625export function planAt(choices: readonly PlanChoice[], t: number): string | null {
626 let plan: string | null = null
627 for (const c of choices) if (c.at <= t) plan = c.plan
628 return plan
629}
630
631// The plan picked last, passing over "not sure".
632export function pickedPlan(choices: readonly PlanChoice[]): string | null {
633 return [...choices].reverse().find(c => c.plan !== null)?.plan ?? null
634}
635
636// A move in share per API dollar this large, between stretches of 3 windows or more, reads as a
637// change of plan: smaller ones are the noise of how a window was used.
638const LOG_JUMP = Math.log(1.8)
639const MIN_STRETCH = 3
640
641// Splits a series into stretches with a level of their own: the cut with the clearest difference
642// of means, while that difference is at least LOG_JUMP and 4 spreads of the means (the spread
643// within the two sides, or the one given if larger), then each half again, three levels deep at
644// most.
645export function stretchesOf(xs: readonly number[], spread: number): [number, number][] {
646 const mean = (a: number, b: number) => xs.slice(a, b).reduce((acc, x) => acc + x, 0) / (b - a)
647 const squares = (a: number, b: number) => {
648 const m = mean(a, b)
649 return xs.slice(a, b).reduce((acc, x) => acc + (x - m) ** 2, 0)
650 }
651 const out: [number, number][] = []
652 const split = (a: number, b: number, depth: number) => {
653 let best = -1
654 let bestZ = 4
655 if (depth < 3) {
656 for (let k = a + MIN_STRETCH; k <= b - MIN_STRETCH; k += 1) {
657 const d = Math.abs(mean(a, k) - mean(k, b))
658 const within = Math.max(spread, Math.sqrt((squares(a, k) + squares(k, b)) / (b - a - 2)))
659 const z = d / (within * Math.sqrt(1 / (k - a) + 1 / (b - k)))
660 if (d >= LOG_JUMP && z >= bestZ) {
661 bestZ = z
662 best = k
663 }
664 }
665 }
666 if (best < 0) {
667 out.push([a, b])
668 return
669 }
670 split(a, best, depth + 1)
671 split(best, b, depth + 1)
672 }
673 if (xs.length > 0) split(0, xs.length, 0)
674 return out
675}
676
677// The plan sold at about this multiple of another, within a factor of 2; null when none is, or
678// when the other is not sold as a multiple.
679export function guessPlan(ratio: number, against: string): string | null {
680 const base = NOMINAL[against]
681 if (base === undefined || !(ratio > 0)) return null
682 let best: string | null = null
683 let miss = Math.LN2
684 for (const [plan, n] of Object.entries(NOMINAL)) {
685 const d = Math.abs(Math.log(ratio) - Math.log(n / base))
686 if (plan !== against && d <= miss) {
687 miss = d
688 best = plan
689 }
690 }
691 return best
692}
693
694// Fits one window kind, with each window counted under the plan picked when it began. Windows no
695// pick covers count under the plan picked next (else the last one picked, else the current plan),
696// unless their share per API dollar stood apart: a first fit gives each window how far its rise
697// strayed from what its spend predicts, and stretches that moved by LOG_JUMP or more from the ones
698// next to the picks are each fitted again as a plan of their own, then named by the sold multiple
699// they come closest to.
700export function fitKind(kind: string, all: readonly Observation[], choices: readonly PlanChoice[] = [], borrow: Fit | null = null): Fit {
701 const none: Fit = {
702 kind, windows: 0, segments: 0, hits: 0, f: {}, g: [0, 0, 0, 0], gBand: [], s: {}, refPlan: CURRENT, planWindows: {},
703 windowPlans: {}, latest: null, inferred: [], mae: null, outliers: 0, confidence: 'none', ref: null,
704 }
705 const raw = segmentsOf(kind, all)
706 if (raw.length === 0) return none
707 const sorted = [...choices].sort((a, b) => a.at - b.at)
708 const length = windowLength(kind) ?? 0
709 const windows = [...new Set(raw.map(sg => sg.window))].sort((a, b) => a - b)
710 const picked = windows.map(w => planAt(sorted, w - length))
711 const preferRef = pickedPlan(sorted) ?? CURRENT
712
713 // Each stretch of windows no pick covers, and the plan it counts under until shown otherwise.
714 const runs: { from: number; to: number; plan: string }[] = []
715 for (let i = 0; i < windows.length; i += 1) {
716 if (picked[i] !== null || (i > 0 && picked[i - 1] === null)) continue
717 let j = i
718 while (j + 1 < windows.length && picked[j + 1] === null) j += 1
719 const next = sorted.find(c => c.at > windows[j]! - length && c.plan !== null)
720 runs.push({ from: i, to: j, plan: next?.plan ?? preferRef })
721 }
722 const labels = picked.map(p => p ?? '')
723 for (const run of runs) for (let i = run.from; i <= run.to; i += 1) labels[i] = run.plan
724 const withLabels = () => {
725 const at = new Map(windows.map((w, i) => [w, labels[i]!]))
726 return raw.map(sg => ({ ...sg, plan: at.get(sg.window)! }))
727 }
728 const first = fitSegments(kind, withLabels(), preferRef, borrow)
729
730 // How far each window's rise strayed from its spend, in logs: positive where a dollar took more.
731 const predicted = (fit: Fit, sg: Segment) =>
732 [0, 1, 2, 3].reduce((acc, k) => acc + fit.g[k]! * Object.entries(sg.c).reduce((a, [m, c]) => a + (fit.f[m] ?? 1) * (c[k] ?? 0), 0), 0)
733 const strays = windows.map((w, i) => {
734 const xs = raw
735 .filter(sg => sg.window === w)
736 .map(sg => Math.log((sg.dy * (first.s[labels[i]!] ?? 1)) / predicted(first, sg)))
737 .filter(Number.isFinite)
738 return median(xs)
739 })
740
741 const inferredRuns: { label: string; members: number[]; against: string }[] = []
742 for (const run of runs) {
743 // The run, with the picked windows next to it under the same plan as its anchor.
744 const anchors: number[] = []
745 for (let j = run.to + 1; j < windows.length && picked[j] === run.plan && anchors.length < 12; j += 1) anchors.push(j)
746 if (anchors.length === 0) for (let j = run.from - 1; j >= 0 && picked[j] === run.plan && anchors.length < 12; j -= 1) anchors.unshift(j)
747 const members = [...new Set([...anchors, ...Array.from({ length: run.to - run.from + 1 }, (_, k) => run.from + k)])]
748 .sort((a, b) => a - b)
749 .filter(i => strays[i] !== null)
750 if (members.length < 2 * MIN_STRETCH) continue
751 const xs = members.map(i => strays[i]!)
752 const steps = xs.slice(1).map((x, k) => Math.abs(x - xs[k]!))
753 const spread = Math.max(0.05, (1.4826 * (median(steps) ?? 0)) / Math.SQRT2)
754 const parts = stretchesOf(xs, spread)
755 if (parts.length < 2) continue
756 const level = ([a, b]: [number, number]) => xs.slice(a, b).reduce((acc, x) => acc + x, 0) / (b - a)
757 const home = parts.find(([a, b]) => members.slice(a, b).some(i => anchors.includes(i))) ?? parts[parts.length - 1]!
758 for (const part of parts) {
759 if (part === home || Math.abs(level(part) - level(home)) < LOG_JUMP) continue
760 const own = members.slice(part[0], part[1]).filter(i => !anchors.includes(i))
761 if (own.length === 0) continue
762 const label = `~${inferredRuns.length + 1}`
763 for (const i of own) labels[i] = label
764 inferredRuns.push({ label, members: own, against: run.plan })
765 }
766 }
767
768 const fit = inferredRuns.length === 0 ? first : fitSegments(kind, withLabels(), preferRef, borrow)
769 const inferred: InferredPlan[] = inferredRuns.map(({ label, members, against }) => {
770 const ratio = (fit.s[label] ?? 1) / (planScale(fit, against) ?? 1)
771 return {
772 label,
773 from: windows[members[0]!]! - length,
774 to: windows[members[members.length - 1]!]!,
775 windows: members.length,
776 ratio,
777 against,
778 guess: guessPlan(ratio, against),
779 }
780 })
781 return {
782 ...fit,
783 windowPlans: Object.fromEntries(windows.map((w, i) => [String(w), labels[i]!])),
784 latest: labels[labels.length - 1] ?? null,
785 inferred,
786 }
787}
788
789// The share of a plan's window a spend takes, by the fit.
790export function shareOf(fit: Fit, plan: string, byModel: Record<string, Vec>): number {
791 const s = planScale(fit, plan) ?? 1
792 let share = 0
793 for (const [m, v] of Object.entries(byModel)) {
794 share += ((fit.f[m] ?? 1) * spendByKind(m, v).reduce((acc, x, k) => acc + fit.g[k]! * x, 0)) / s
795 }
796 return share
797}
798
799export type FittedEstimate = {
800 plan: string
801 capacityUsd: number
802 remainingUsd: number
803 explained: number
804 etaMs: number | null
805 // The share used at the reset when it does not fill before; null when there is no reset time.
806 atReset: number | null
807}
808
809// Reads the current window through a fit. What is left comes from the official percent (so other
810// devices' use counts). Dollars are told at this window's own way of using it: its API spend over
811// the share the fit gives that spend. The coming hours each spend what that hour of the day has
812// averaged, by model and kind, until what is left is used.
813export function fittedWindow(
814 w: Window,
815 fit: Fit | undefined,
816 plan: string | null,
817 windowByModel: Record<string, Vec> | undefined,
818 hourly: Record<string, number[][]>,
819 tzOffsetMin: number,
820 now: number,
821): FittedEstimate | null {
822 if (fit === undefined || fit.confidence === 'none') return null
823 const s = planScale(fit, plan ?? CURRENT)
824 if (s === null) return null
825
826 const y = w.percentUsed / 100
827 const left = Math.max(0, 1 - y)
828 const explained = shareOf(fit, plan ?? CURRENT, windowByModel ?? {})
829 const windowUsd = Object.entries(windowByModel ?? {}).reduce((acc, [m, v]) => acc + costOf(m, v), 0)
830
831 const perHour = new Array<number>(24).fill(0)
832 const usdHour = new Array<number>(24).fill(0)
833 for (const [m, hours] of Object.entries(hourly)) {
834 hours.forEach((kinds, hour) => {
835 perHour[hour]! += ((fit.f[m] ?? 1) * kinds.reduce((acc, x, k) => acc + fit.g[k]! * x, 0)) / s
836 usdHour[hour]! += kinds.reduce((a, b) => a + b, 0)
837 })
838 }
839 // Dollars per whole window: this window's if it has spent enough to say, else the day's average.
840 const profileShare = perHour.reduce((a, b) => a + b, 0)
841 const usdPerWindow = explained >= 0.03 ? windowUsd / explained : profileShare > 0 ? usdHour.reduce((a, b) => a + b, 0) / profileShare : 0
842
843 const reset = Date.parse(w.resetsAt ?? '')
844 let etaMs: number | null = null
845 let used = 0
846 if (!Number.isNaN(reset)) {
847 const offset = tzOffsetMin * MINUTE
848 let t = now
849 while (t < reset && etaMs === null) {
850 const end = Math.min((Math.floor((t + offset) / HOUR) + 1) * HOUR - offset, reset)
851 const rate = perHour[new Date(t + offset).getUTCHours()]! / HOUR
852 if (rate > 0 && used + rate * (end - t) >= left) etaMs = t + (left - used) / rate - now
853 used += rate * (end - t)
854 t = end
855 }
856 }
857 const atReset = Number.isNaN(reset) || etaMs !== null ? null : Math.min(1, y + used)
858 return { plan: plan ?? CURRENT, capacityUsd: usdPerWindow, remainingUsd: left * usdPerWindow, explained, etaMs, atReset }
859}
860
861// ---- Engine calls ---------------------------------------------------------
862
863// Claude Code's own folder: CLAUDE_CONFIG_DIR, else ~/.claude.
864async function configDir($: EngineInterface): Promise<string> {
865 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
866 return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
867}
868
869// Where Claude Code keeps this session's transcript: <config>/projects/<root, each
870// character outside [A-Za-z0-9] made '-'>/<session id>.jsonl.
871async function transcriptPath($: EngineInterface): Promise<string> {
872 const id = await $.session.id()
873 const root = await $.session.root()
874 return `${await configDir($)}/projects/${root.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
875}
876
877// Fills the session's counts from its transcript and its subagents', so what ran before the mod
878// loaded counts. The scanner reads them: a transcript outgrows what `$.fs.read` takes (4 MiB).
879// Answers whether it read one; the live counts stand when it cannot.
880async function backfill($: EngineInterface, path: string): Promise<boolean> {
881 const run = await $.process.run([nodeBin, `${$.plugin.root}/scan/scan.mjs`, 'session', path], { timeoutMs: 60_000 }).catch(() => null)
882 if (run === null || run.exitCode !== 0) {
883 $.ui.log(`token-almanac: could not read ${path}: ${run?.stderr.split('\n')[0] ?? 'node did not run'}`, { to: 'debug' })
884 return false
885 }
886 const parsed = JSON.parse(run.stdout) as Parsed
887 const startedAt = (await $.session.usage()).startedAt
888 await update($, totals, () => parsed.totals)
889 await update($, byModel, () => parsed.byModel)
890 await update($, turns, () => parsed.turns)
891 await update($, since, () => startedAt)
892 return true
893}
894
895// Reads every transcript on this machine through the scanner (incremental: its cache keeps each
896// file's read offset) and keeps the sums for the global view.
897const NODE_MISSING = 'node-missing'
898
899// The language: the `language` option, else Claude Code's `language` setting, else LC_ALL or LANG,
900// else what the runtime says the system's locale is; English when none names one spoken here.
901export function pickLocale(option: string, ...sources: (string | null | undefined)[]): Locale {
902 const chosen = localeOf(option)
903 if (option !== 'auto' && chosen !== null) return chosen
904 for (const source of sources) {
905 const locale = localeOf(source)
906 if (locale !== null) return locale
907 }
908 return 'en'
909}
910
911async function resolveLocale($: EngineInterface, option: string): Promise<Locale> {
912 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
913 let system: string | undefined
914 try {
915 system = Intl.DateTimeFormat().resolvedOptions().locale
916 } catch {
917 system = undefined
918 }
919 return pickLocale(option, typeof settings.language === 'string' ? settings.language : null, await $.env.get('LC_ALL'), await $.env.get('LANG'), system)
920}
921
922async function loadPriceOverrides($: EngineInterface): Promise<void> {
923 try {
924 setPriceOverrides(JSON.parse(await $.fs.read(`${await configDir($)}/token-almanac/prices.json`)) as Record<string, unknown>)
925 } catch {
926 setPriceOverrides({})
927 }
928}
929
930// Whether a scan is in flight, so no second one starts beside it; what the last one read, so a
931// pick fits again without reading the transcripts; and the fits of the last observations and
932// picks, kept while neither changes.
933let isScanning = false
934let lastScan: GlobalScan | null = null
935let fitMemo: { key: string; fits: Record<string, Fit> } | null = null
936
937// The history of windows that have reset, kept for good in the store: each window's observations
938// cut down to those the fit reads, with spend exact to the response. Readings stay in their log
939// only while their window runs, so its cap holds open windows, and a window's pieces keep their
940// spend however old they grow (the scanner keeps spend response by response for 8 days only).
941const ARCHIVE_MAX = 20_000
942const windowKey = (o: { kind: string; resetsAt: number }) => `${o.kind}|${o.resetsAt}`
943// When this process last looked for windows to put into the history.
944let archiveCheckedAt = 0
945
946// Puts each window that has reset, has readings and still has exact spend into the history, and
947// takes its readings out of the log. A window whose spend is no longer exact is left as it is:
948// archiving it would keep its error for good. Returns the whole history.
949async function archiveClosed($: EngineInterface, scan: GlobalScan, now: number): Promise<Observation[]> {
950 const archive = ((await $.store.get('archive')) ?? []) as Observation[]
951 const known = new Set(archive.map(windowKey))
952 const byWindow = new Map<string, Observation[]>()
953 for (const o of scan.observations) {
954 const key = windowKey(o)
955 if (!known.has(key)) byWindow.set(key, [...(byWindow.get(key) ?? []), o])
956 }
957 const fresh = [...byWindow.values()]
958 .filter(list => list[0]!.resetsAt <= now && list.some(o => !o.hit) && list.every(o => o.exact === true))
959 .flatMap(compactWindow)
960 if (fresh.length === 0) return archive
961 const next = [...archive, ...fresh].sort((a, b) => a.resetsAt - b.resetsAt || a.t - b.t).slice(-ARCHIVE_MAX)
962 await $.store.set('archive', next)
963 const done = new Set(next.map(windowKey))
964 const log = ((await $.store.get('readings')) ?? []) as Reading[]
965 const open = log.filter(r => !done.has(windowKey({ kind: r[1], resetsAt: r[3] })))
966 if (open.length !== log.length) await $.store.set('readings', open)
967 return next
968}
969
970// A scan the user asked for says so while it runs. One in the background (after a turn, on opening
971// the view) keeps the figures shown until the new ones replace them, all in one change.
972async function scanAll($: EngineInterface, isShown = false): Promise<void> {
973 if (isScanning) return
974 isScanning = true
975 // One redraw, so a terminal pane starts its spinner.
976 $.ui.invalidate('ui.render')
977 if (isShown) await update($, scanStatus, () => ({ state: 'scanning' as const }))
978 try {
979 const config = await configDir($)
980 const snap = await read($, snapshot)
981 const windows = Object.fromEntries(
982 (snap?.windows ?? []).flatMap(w => {
983 const length = windowLength(w.kind)
984 const reset = Date.parse(w.resetsAt ?? '')
985 return length === null || Number.isNaN(reset) ? [] : [[w.kind, reset - length]]
986 }),
987 )
988 const kept = new Set((((await $.store.get('archive')) ?? []) as Observation[]).map(windowKey))
989 const readings = (((await $.store.get('readings')) ?? []) as Reading[]).filter(r => !kept.has(windowKey({ kind: r[1], resetsAt: r[3] })))
990 const run = await $.process.run([nodeBin, `${$.plugin.root}/scan/scan.mjs`, 'global', `${config}/projects`, `${config}/token-almanac/cache.json`], {
991 timeoutMs: 300_000,
992 stdin: JSON.stringify({ windows, readings }),
993 })
994 if (run.exitCode !== 0) throw new Error(run.stderr.split('\n')[0] || `scanner exited ${run.exitCode}`)
995 const scan = JSON.parse(run.stdout) as GlobalScan
996 // The fit reads the history and the scan's windows not in it (a hit the scanner keeps is there).
997 const archive = await archiveClosed($, scan, await $.clock.now())
998 archiveCheckedAt = await $.clock.now()
999 const archived = new Set(archive.map(windowKey))
1000 lastScan = { ...scan, observations: [...archive, ...scan.observations.filter(o => !archived.has(windowKey(o)))] }
1001 await refit($)
1002 if ((await read($, scanStatus)).state !== 'idle') await update($, scanStatus, () => ({ state: 'idle' as const }))
1003 } catch (error) {
1004 const message = error instanceof Error ? error.message : String(error)
1005 // A Node.js that cannot be started is said plainly; the pane words it.
1006 const isNodeMissing = /ENOENT|not found|cannot find|no such file|spawn/i.test(message)
1007 await update($, scanStatus, () => ({ state: 'error' as const, message: isNodeMissing ? NODE_MISSING : message }))
1008 } finally {
1009 isScanning = false
1010 }
1011}
1012
1013// Works out what the machine view draws from the last scan and the plans picked, and sets it in
1014// one change. The fits are the slow part: they are kept while the observations and picks stay the
1015// same, and the pane gets a turn between one window kind's and the next.
1016async function refit($: EngineInterface): Promise<void> {
1017 const scan = lastScan
1018 if (scan === null) return
1019 const snap = await read($, snapshot)
1020 const choices = await read($, planChoices)
1021 const kinds = [...new Set([...scan.observations.map(o => o.kind), ...(snap?.windows ?? []).map(w => w.kind)])].sort()
1022 const key = JSON.stringify([scan.observations.length, scan.observations.reduce((a, o) => Math.max(a, o.t), 0), choices, kinds])
1023 let fits = fitMemo?.key === key ? fitMemo.fits : null
1024 if (fits === null) {
1025 // The five-hour window first: the others lean on it once it is sure enough.
1026 const fresh: Record<string, Fit> = {}
1027 for (const kind of [...kinds].sort((a, b) => (b === 'five_hour' ? 1 : 0) - (a === 'five_hour' ? 1 : 0))) {
1028 await $.clock.sleep(0)
1029 const lend = fresh.five_hour
1030 fresh[kind] = fitKind(kind, scan.observations, choices, kind !== 'five_hour' && lend !== undefined && (lend.confidence === 'medium' || lend.confidence === 'high') ? lend : null)
1031 }
1032 fits = fresh
1033 fitMemo = { key, fits }
1034 }
1035 await $.clock.sleep(0)
1036 const views = { '30d': globalView(scan, '30d'), all: globalView(scan, 'all') }
1037 // Each limit hit's API-equivalent cost, for the windows counted under the plan in force now.
1038 const hits: Record<string, number[]> = {}
1039 for (const o of scan.observations) {
1040 const fit = fits[o.kind]
1041 if (!o.hit || (fit !== undefined && fit.windowPlans[String(o.resetsAt)] !== fit.latest)) continue
1042 const cost = Object.entries(o.byModel).reduce((acc, [m, v]) => acc + costOf(m, v), 0)
1043 if (cost > 0) (hits[o.kind] ??= []).push(cost)
1044 }
1045 const next: Machine = { at: scan.at, files: scan.files, tzOffsetMin: scan.tzOffsetMin, windows: scan.windows, views, fits, hits }
1046 await update($, machine, () => next)
1047}
1048
1049// Takes a reading: the snapshot the band and pane draw, and a forecast sample per window,
1050// kept in the store too so the forecast survives a restart within the same window.
1051async function record($: EngineInterface, u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Promise<void> {
1052 const now = await $.clock.now()
1053 const snap = toSnapshot(u, now)
1054 await update($, snapshot, () => snap)
1055 const next = await update($, samples, all => snap.windows.reduce((acc, w) => withReading(acc, w, now), all))
1056 await $.store.set('samples', next)
1057
1058 // Every reading that moved, across windows and sessions: the fit's observations.
1059 const log = ((await $.store.get('readings')) ?? []) as Reading[]
1060 let isGrown = false
1061 for (const w of snap.windows) {
1062 const reset = Date.parse(w.resetsAt ?? '')
1063 if (Number.isNaN(reset)) continue
1064 const last = [...log].reverse().find(r => r[1] === w.kind)
1065 if (last && last[2] === w.percentUsed && last[3] === reset) continue
1066 log.push([now, w.kind, w.percentUsed, reset])
1067 isGrown = true
1068 }
1069 if (isGrown) await $.store.set('readings', log.slice(-3000))
1070 // A window in the log has reset: scan behind everything, so it goes into the history while its
1071 // spend is still exact, whether or not the machine view is ever opened. At most twice an hour.
1072 if (log.some(r => r[3] <= now) && now - archiveCheckedAt > 30 * MINUTE) {
1073 archiveCheckedAt = now
1074 $.clock.after(0, () => void scanAll($))
1075 }
1076}
1077
1078// Picks the plan in force from now on. A pick made less than a day ago is taken as a correction of
1079// it, keeping its moment; an earlier one stays, so the windows before now keep the plan they had.
1080async function pickPlan($: EngineInterface, value: string): Promise<void> {
1081 const plan = value === UNKNOWN ? null : value
1082 const now = await $.clock.now()
1083 const next = await update($, planChoices, list => {
1084 const last = list[list.length - 1]
1085 if (last !== undefined && last.plan === plan) return list
1086 if (last !== undefined && now - last.at < 24 * HOUR) return [...list.slice(0, -1), { at: last.at, plan }]
1087 return list.length === 0 && plan === null ? list : [...list, { at: now, plan }]
1088 })
1089 await $.store.set('planChoices', next)
1090 // A reload forgets the last scan: read the transcripts again for it.
1091 if (lastScan === null) await scanAll($)
1092 else await refit($)
1093}
1094
1095function ago(ms: number): string {
1096 return ms < MINUTE ? t.updatedNow : t.updatedAgo(fmtDuration(ms))
1097}
1098
1099// ---- Drawing --------------------------------------------------------------
1100
1101// Claude's clay is the one accent; warm neutrals carry everything else. Amber and red appear only
1102// when a limit runs high.
1103const CLAY = '#D97757'
1104const CLAY_SOFT = '#E9A88C'
1105const STONE = '#A39E93'
1106const AMBER = '#D99A3A'
1107const RED = '#C9533E'
1108
1109function levelHex(percent: number): string {
1110 return percent >= 90 ? RED : percent >= 70 ? AMBER : CLAY
1111}
1112
1113// ---- Motion -------------------------------------------------------------------
1114
1115// Whether figures move into place, from the `motion` option.
1116let isMotion = true
1117// How long a figure takes to move to a new value, and how often the terminal draws it on the way.
1118const MOVE_MS = 450
1119const FRAME_MS = 50
1120// A figure on its way: the value it left, the one it goes to, and when it set off.
1121export type Move = { from: number; to: number; at: number }
1122// Each figure by where it is drawn: `t:` keys in the terminal, `g:` keys in vector drawings.
1123const moves = new Map<string, Move>()
1124const easeOut = (x: number) => 1 - (1 - x) ** 3
1125
1126// The move that takes a figure to this value. It is kept while the value stays, so drawing the
1127// same value draws the same thing (a vector drawing does not play again); a new value sets off
1128// from wherever the figure is now, and a figure seen for the first time rises from `start`.
1129export function moveTo(key: string, value: number, now: number, start = 0): Move {
1130 const last = moves.get(key)
1131 if (last !== undefined && last.to === value) return last
1132 const from = !isMotion ? value : last === undefined ? start : positionOf(last, now)
1133 const move = { from, to: value, at: now }
1134 moves.set(key, move)
1135 return move
1136}
1137
1138export function positionOf(move: Move, now: number): number {
1139 const x = Math.min(1, Math.max(0, (now - move.at) / MOVE_MS))
1140 return move.from + (move.to - move.from) * easeOut(x)
1141}
1142
1143const isOnTheWay = (move: Move, now: number) => move.from !== move.to && now - move.at < MOVE_MS
1144
1145// SMIL that moves one attribute of a shape from where it was to where it is drawn, easing out;
1146// nothing once the move is over, so a surface that draws the frame afresh (on scroll, on the
1147// half-minute redraw) shows the figure where it stands rather than playing the move again.
1148function glide(attribute: string, move: Move, now: number): string {
1149 if (!isOnTheWay(move, now)) return ''
1150 return `<animate attributeName="${attribute}" from="${move.from.toFixed(1)}" to="${move.to.toFixed(1)}" dur="${MOVE_MS}ms" calcMode="spline" keyTimes="0;1" keySplines="0.22 1 0.36 1" fill="freeze"/>`
1151}
1152
1153// The braille spinner a scan turns while it runs.
1154const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
1155const spinnerAt = (now: number) => SPINNER[Math.floor(now / 80) % SPINNER.length]!
1156
1157// The terminal is drawn again every frame while a figure there is on its way or a scan runs, and
1158// the frames stop when neither is. Only a terminal drawing starts them: on the other surfaces a
1159// redraw every frame would replace the buttons under a press, so a click could be lost.
1160let ticker: { cancel: () => void } | null = null
1161function animate($: EngineInterface): void {
1162 if (ticker !== null || !isMotion) return
1163 ticker = $.clock.every(FRAME_MS, () => {
1164 void $.clock.now().then(now => {
1165 const isBusy = isScanning || [...moves].some(([key, move]) => key.startsWith('t:') && isOnTheWay(move, now - FRAME_MS))
1166 $.ui.invalidate('ui.render')
1167 if (!isBusy) {
1168 ticker?.cancel()
1169 ticker = null
1170 }
1171 })
1172 })
1173}
1174
1175// The terminal's sparkline: one block per value, eight heights.
1176function sparkText(values: readonly number[]): string {
1177 const blocks = '▁▂▃▄▅▆▇█'
1178 const max = Math.max(...values, 1e-9)
1179 return values.map(v => (v <= 0 ? ' ' : blocks[Math.min(7, Math.floor((v / max) * 7.999))]!)).join('')
1180}
1181
1182// ---- Cards --------------------------------------------------------------------
1183
1184// The desktop, editor and phone draw the pane's figures as whole images that set their own type
1185// and spacing, so the pane reads as a few calm cards rather than rows of text beside small bars.
1186// They are images, not frames: nothing reloads when the pane redraws or scrolls.
1187const CARD_W = 400
1188const FONT = "system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif"
1189// Ink, hairlines and the model series by role, light and dark: an image follows the viewer's
1190// colour scheme. The series are the first three of a palette checked for colour-blind separation.
1191const CARD_STYLE =
1192 '<style>' +
1193 '.i1{fill:#1f1e1b}.i2{fill:#5e5d59}.i3{fill:#8a8780}.trk{fill:#ebe8e1}.trkS{stroke:#ebe8e1}.hair{stroke:#e6e3dc}.tk{stroke:#5e5d59}' +
1194 '.s0{fill:#2a78d6}.s1{fill:#eb6834}.s2{fill:#1baf7a}.s3{fill:#b4b2a9}' +
1195 '@media (prefers-color-scheme: dark){' +
1196 '.i1{fill:#f4f3ee}.i2{fill:#c3c2b7}.i3{fill:#8f8d86}.trk{fill:#33322e}.trkS{stroke:#33322e}.hair{stroke:#33322e}.tk{stroke:#c3c2b7}' +
1197 '.s0{fill:#3987e5}.s1{fill:#d95926}.s2{fill:#199e70}.s3{fill:#6b6a64}}' +
1198 '</style>'
1199// The models in fixed order: the three largest, then the rest as one.
1200const MODEL_SERIES = ['s0', 's1', 's2', 's3'] as consthooks/i18n.ts 238 lines1// The words token-almanac draws, in each language it speaks. English is the fallback and the
2// shape every other table follows; a new language is one more table of the same type.
3
4export type Locale = 'en' | 'zh'
5
6const en = {
7 kinds: { input: 'input', output: 'output', cacheRead: 'cache read', cacheWrite: 'cache write' },
8 windows: {
9 five_hour: '5-hour',
10 seven_day: 'Weekly',
11 seven_day_opus: 'Weekly Opus',
12 seven_day_sonnet: 'Weekly Sonnet',
13 spend_limit: 'Spend limit',
14 } as Record<string, string>,
15 plans: {
16 pro: 'Pro',
17 max5x: 'Max 5x',
18 max20x: 'Max 20x',
19 team: 'Team',
20 team_premium: 'Team Premium',
21 enterprise: 'Enterprise',
22 current: 'Current plan',
23 } as Record<string, string>,
24 confidence: { none: 'no data', low: 'low', medium: 'medium', high: 'high' },
25
26 resetsIn: (d: string) => `resets in ${d}`,
27 resetting: 'resetting',
28 fullIn: (d: string) => `full in ~${d}`,
29 full: 'used up',
30 flat: 'not rising lately',
31 safe: 'on pace to last',
32 atReset: (p: string) => `~${p}% at reset`,
33 updatedNow: 'just now',
34 updatedAgo: (d: string) => `${d} ago`,
35
36 paneTitle: 'Token usage',
37 paneOpened: 'Token usage pane opened.',
38 paneWaiting: 'The token usage pane is waiting for room to open.',
39 commandDescription: 'Open the token usage pane and show its band again',
40
41 bandContext: (p: number) => `context ${p}%`,
42 bandFull: (d: string) => `full in ${d}`,
43 bandWaiting: 'limits: waiting for a reply',
44 bandNoLimits: 'API billing · no plan limits',
45 bandOpen: 'Details',
46 bandHide: 'Hide',
47
48 tabSession: 'This session',
49 tabGlobal: 'This machine',
50 waitingLimits: 'Limits show after the next reply',
51 noLimits: 'This account has no plan limits (API billing)',
52 close: 'Close',
53
54 statCost: 'API-equivalent',
55 statTokens: 'tokens',
56 statContext: (w: string) => `of ${w} context`,
57 statTurns: 'turns',
58 statRange30: 'last 30 days',
59 statRangeAll: 'all time',
60 statPerDay: 'per day',
61 statSpan: 'span',
62 days: (n: number) => `${n} d`,
63
64 composition: 'Makeup',
65 byCost: 'by API-equivalent cost',
66 perTurn: 'Turns',
67 perTurnAside: (n: number, peakN: number, peak: string) => `last ${n} · peak #${peakN} ${peak}`,
68 noTurns: 'No finished turns yet',
69 turnTitle: (n: number, tokens: string, out: string, write: string, read: string, subagents: number) =>
70 `Turn ${n} ${tokens} tokens\noutput ${out} · cache write ${write} · cache read ${read}${subagents ? `\nwith ${subagents} subagent${subagents > 1 ? 's' : ''}` : ''}`,
71 models: 'Models',
72 projects: 'Projects',
73 daily: 'Daily',
74 peak: (usd: string) => `peak ${usd}`,
75 noUsage: 'No usage in this range',
76 other: 'other',
77 sessionNote: (d: string) => `Covers this session's ${d}, subagents included · API-equivalent at list prices`,
78
79 scanning: 'Scanning…',
80 scanFailed: (m: string) => `Scan failed: ${m}`,
81 nodeMissing: 'Reading history needs Node.js 18+ (set its path in /config). Session figures still work.',
82 files: (n: number, ago: string) => `${n} files · ${ago}`,
83 range30: '30 days',
84 rangeAll: 'All',
85 refresh: 'Refresh',
86 history: 'Usage history',
87 keyElapsed: 'time through the window',
88 keyProjected: 'where it is headed by the reset',
89 showMore: 'Show breakdown',
90 showLess: 'Hide breakdown',
91 firstScan: 'The first scan reads every transcript under your Claude config folder and takes a few seconds; later ones read only what is new.',
92
93 capacity: 'Capacity',
94 capacityAside: (plan: string) => `${plan} · API-equivalent`,
95 colWhole: 'Window',
96 colLeft: 'Left',
97 colOutlook: 'At your usual pace',
98 colConfidence: 'Confidence',
99 capacityNone: 'Not enough history yet: estimates start once a window has risen 5 points.',
100 hitsLine: (kind: string, n: number, lo: string, hi: string) => `${kind}: hit the limit ${n} time${n > 1 ? 's' : ''}, at ${n > 1 ? `${lo}–${hi}` : lo} each`,
101 details: 'How it is estimated',
102 hideDetails: 'Hide estimate details',
103 fitHead: (conf: string, segments: number, hits: number) => `· confidence ${conf} · ${segments} pieces · ${hits} limit hits`,
104 fitCounts: (segments: number, hits: number) => `${segments} pieces · ${hits} limit hits`,
105 fitMiss: (x: string) => ` · off by ${x} pts`,
106 fitOutliers: (n: number) => ` · ${n} outliers`,
107 fitLent: (kind: string) => ` · leaning on the ${kind} fit`,
108 fitUnseen: (n: number) => ` · ${n} piece${n > 1 ? 's' : ''} likely with use from elsewhere, set aside`,
109 kindWeights: 'Per API dollar, against output: ',
110 undetermined: 'unclear',
111 against: (ref: string) => `Against ${ref}: `,
112 multipleNote: (nominal: number | null, n: number) => (nominal !== null ? ` (sold as ×${nominal}, ${n} windows)` : ` (${n} windows)`),
113 planPick: 'Plan',
114 planUnknown: 'Not sure',
115 planHint: 'Pick your plan to have estimates told against it; until then they are relative to whatever plan you are on.',
116 inferredLine: (from: string, to: string, ratio: string, against: string, guess: string | null, n: number) =>
117 `${from} – ${to}: ×${ratio} of ${against}${guess !== null ? `, likely ${guess}` : ''} (inferred, ${n} windows)`,
118 dataNote:
119 'From the Claude Code transcripts on this machine (subagents included); claude.ai and other devices are not in them. API-equivalent uses list prices (override in token-almanac/prices.json). Estimates fit pieces of 5 points or more and past limit hits, as model factor × token kind factor and plan multiples. Before the plan you pick, a stretch whose rate moved by 1.8× or more is taken as another plan. How limits are counted is not published: these are inferences.',
120 unpriced: (list: string) => ` Not priced, counted as 0: ${list}.`,
121}
122
123export type Messages = typeof en
124
125const zh: Messages = {
126 kinds: { input: 'input', output: 'output', cacheRead: 'cache 读', cacheWrite: 'cache 写' },
127 windows: { five_hour: '5 小时', seven_day: '每周', seven_day_opus: '每周 Opus', seven_day_sonnet: '每周 Sonnet', spend_limit: '花费上限' },
128 plans: { pro: 'Pro', max5x: 'Max 5x', max20x: 'Max 20x', team: 'Team', team_premium: 'Team Premium', enterprise: 'Enterprise', current: '当前套餐' },
129 confidence: { none: '无数据', low: '低', medium: '中', high: '高' },
130
131 resetsIn: d => `${d}后重置`,
132 resetting: '即将重置',
133 fullIn: d => `约 ${d} 后用满`,
134 full: '已用满',
135 flat: '近期未增长',
136 safe: '按当前节奏用不满',
137 atReset: p => `重置时约 ${p}%`,
138 updatedNow: '刚刚更新',
139 updatedAgo: d => `${d}前更新`,
140
141 paneTitle: 'Token 用量',
142 paneOpened: 'Token 用量面板已打开。',
143 paneWaiting: 'Token 用量面板等待空间打开。',
144 commandDescription: '打开 Token 用量面板,并重新显示横条',
145
146 bandContext: p => `上下文 ${p}%`,
147 bandFull: d => `${d} 后满`,
148 bandWaiting: '限额:等待首个响应',
149 bandNoLimits: 'API 计费 · 无订阅限额',
150 bandOpen: '详情',
151 bandHide: '隐藏',
152
153 tabSession: '本次会话',
154 tabGlobal: '本机全局',
155 waitingLimits: '等待首个响应带回限额读数',
156 noLimits: '这个账户没有订阅限额(API 计费)',
157 close: '关闭',
158
159 statCost: '折算花费',
160 statTokens: 'tokens',
161 statContext: w => `上下文 ${w}`,
162 statTurns: '轮',
163 statRange30: '近 30 天折算',
164 statRangeAll: '全部折算',
165 statPerDay: '日均',
166 statSpan: '跨度',
167 days: n => `${n} 天`,
168
169 composition: '构成',
170 byCost: '按折算花费',
171 perTurn: '每轮',
172 perTurnAside: (n, peakN, peak) => `最近 ${n} 轮 · 峰值第 ${peakN} 轮 ${peak}`,
173 noTurns: '还没有完成的轮次',
174 turnTitle: (n, tokens, out, write, read, subagents) =>
175 `第 ${n} 轮 ${tokens} tokens\noutput ${out} · cache 写 ${write} · cache 读 ${read}${subagents ? `\n含子代理 ${subagents} 个` : ''}`,
176 models: '模型',
177 projects: '项目',
178 daily: '每日',
179 peak: usd => `峰值 ${usd}`,
180 noUsage: '这段时间没有用量',
181 other: '其他',
182 sessionNote: d => `覆盖本会话 ${d},含子代理 · 折算按 API 价格`,
183
184 scanning: '扫描中…',
185 scanFailed: m => `扫描失败:${m}`,
186 nodeMissing: '读取历史记录需要 Node.js 18 以上(可在 /config 里设置路径),会话内统计不受影响。',
187 files: (n, ago) => `${n} 个文件 · ${ago}`,
188 range30: '近 30 天',
189 rangeAll: '全部',
190 refresh: '刷新',
191 history: '历史用量',
192 keyElapsed: '窗口时间进度',
193 keyProjected: '按节奏预计到重置',
194 showMore: '展开明细',
195 showLess: '收起明细',
196 firstScan: '首次扫描读取 Claude 配置目录下的全部会话记录,约需数秒;之后只读新增部分。',
197
198 capacity: '容量推算',
199 capacityAside: plan => `${plan} · 按 API 价格折算`,
200 colWhole: '整窗',
201 colLeft: '剩余',
202 colOutlook: '按历史节奏',
203 colConfidence: '可信度',
204 capacityNone: '历史数据不足:窗口用量涨满 5 个点后开始推算。',
205 hitsLine: (kind, n, lo, hi) => `${kind}历史触限 ${n} 次,每次折合 ${n > 1 ? `${lo}–${hi}` : lo}`,
206 details: '推算细节',
207 hideDetails: '收起推算细节',
208 fitHead: (conf, segments, hits) => `· 可信度 ${conf} · ${segments} 段 · ${hits} 次触限`,
209 fitCounts: (segments, hits) => `${segments} 段 · ${hits} 次触限`,
210 fitMiss: x => ` · 误差 ${x} 点`,
211 fitOutliers: n => ` · ${n} 段离群`,
212 fitLent: kind => ` · 参照 ${kind}窗口`,
213 fitUnseen: n => ` · ${n} 段疑似有本机之外的用量,已排除`,
214 kindWeights: '每 API 美元相对 output:',
215 undetermined: '未定',
216 against: ref => `相对 ${ref}:`,
217 multipleNote: (nominal, n) => (nominal !== null ? `(标称 ×${nominal},${n} 个窗口)` : `(${n} 个窗口)`),
218 planPick: '套餐',
219 planUnknown: '不确定',
220 planHint: '选择你的套餐后,推算会按该套餐给出;未选择时按相对倍数显示。',
221 inferredLine: (from, to, ratio, against, guess, n) => `${from} – ${to}:约为 ${against} 的 ×${ratio}${guess !== null ? `,可能是 ${guess}` : ''}(推测,${n} 个窗口)`,
222 dataNote:
223 '数据来自本机的 Claude Code 会话记录(含子代理),不含网页版和其他设备。折算按官方 API 价格(可用 token-almanac/prices.json 覆盖)。推算用每涨 5 个点的分段和历史触限,拟合模型系数 × token 类型系数与套餐倍数;所选套餐之前,比率变化 1.8 倍以上的时段视为另一个套餐。限额的真实算法未公开,结果是推断。',
224 unpriced: list => ` 未定价、按 0 计:${list}。`,
225}
226
227export const MESSAGES: Record<Locale, Messages> = { en, zh }
228
229// A locale from a language name or tag, as Claude Code's `language` setting, LANG or Intl give
230// it ("chinese", "简体中文", "zh_CN.UTF-8", "en-US"); null when it names none we speak.
231export function localeOf(value: string | undefined | null): Locale | null {
232 const v = String(value ?? '').trim().toLowerCase()
233 if (v === '' || v === 'c' || v === 'posix') return null
234 if (/^zh|chinese|中文|汉语|漢語/.test(v)) return 'zh'
235 if (/^en|english/.test(v)) return 'en'
236 return null
237}
238types/index.d.ts 141 lines1// Token counts in the four kinds the API reports.
2export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
3
4// One main-thread turn, with the subagent turns that finished while it ran.
5export type TurnRow = { n: number; at: number; tokens: Tokens; subagentTurns: number }
6
7// One rate-limit window as the last API response reported it.
8export type Window = { kind: string; percentUsed: number; resetsAt?: string }
9
10// The status line's figures, as `$.session.usage()` and `session.measure` give them.
11export type Snapshot = {
12 at: number
13 contextTokens?: number
14 contextWindow: number
15 contextPercent?: number
16 costUsd?: number
17 windows: Window[]
18}
19
20// Readings of one window's percent over time, for the burn-rate forecast.
21export type Samples = { resetsAt?: string; points: [number, number][] }
22
23// [input, output, cacheRead, cacheWrite5m, cacheWrite1h], as the scanner sums them.
24export type Vec = [number, number, number, number, number]
25
26// One local day and hour of one project and model: [day, hour, project, model, ...Vec].
27export type ScanRow = [string, number, string, string, number, number, number, number, number]
28
29// A plan picked in the pane, in force from that moment on; null for "not sure".
30export type PlanChoice = { at: number; plan: string | null }
31
32// A limit reading kept by the mod: [at, window kind, percent used, resets at].
33export type Reading = [number, string, number, number]
34
35// A reading or a limit hit (100% at that moment), with what each model cost in its window so far.
36export type Observation = { t: number; kind: string; y: number; resetsAt: number; hit: boolean; exact?: boolean; byModel: Record<string, Vec> }
37
38// One window kind's fit: rise × plan multiple ≈ Σ f[model] × Σ g[kind] × API spend[model][kind].
39export type Fit = {
40 kind: string
41 windows: number
42 // Pieces of windows (5 points or more each) and limit hits it learned from.
43 segments: number
44 hits: number
45 // Per model, against the reference model (1).
46 f: Record<string, number>
47 // Per kind of token (input, output, cache read, cache write): the share of a Pro window one API
48 // dollar of it takes, on the reference model.
49 g: number[]
50 // 95% band of each kind's weight against output's.
51 gBand: [number, number][]
52 // Per plan: its window's size as a multiple of the reference plan's.
53 s: Record<string, number>
54 // The plan picked now if it has windows, else the one with the most; 'current' stands for
55 // the plan in force when none was ever picked.
56 refPlan: string
57 planWindows: Record<string, number>
58 // The plan each window (keyed by when it resets) was counted under: picked, or inferred.
59 windowPlans: Record<string, string>
60 // The plan of the latest window.
61 latest: string | null
62 // Stretches with no pick whose rate stood apart from the plan next to them.
63 inferred: InferredPlan[]
64 // Median miss, in percentage points; pieces weighed down as unexplained.
65 mae: number | null
66 outliers: number
67 // Pieces that rose far past their spend, set aside as use this machine did not see.
68 unseen?: number
69 confidence: 'none' | 'low' | 'medium' | 'high'
70 // The model with the most spend.
71 ref: string | null
72 // The window kind whose fit this one leaned on for its kinds' and models' weights, if any.
73 lent?: string | null
74}
75
76// A stretch of windows with no pick behind it whose share per API dollar moved apart from the
77// plan next to it, fitted as a plan of its own: its window against that plan's, and the plan sold
78// at about that multiple, if one is.
79export type InferredPlan = { label: string; from: number; to: number; windows: number; ratio: number; against: string; guess: string | null }
80
81// What `scan/scan.mjs global` prints: every transcript on this machine, summed.
82export type GlobalScan = {
83 rows: ScanRow[]
84 windows: Record<string, { start: number; byModel: Record<string, Vec>; byProject: Record<string, Vec> }>
85 observations: Observation[]
86 today: string
87 tzOffsetMin: number
88 files: number
89 readBytes: number
90 ms: number
91 at: number
92}
93
94// The machine view over one range: cost per day, project and model, and each local hour's average.
95export type GlobalView = {
96 days: number
97 totalCost: number
98 totalTokens: number
99 daily: { day: string; byModel: Record<string, number> }[]
100 byProject: { project: string; cost: number; tokens: number }[]
101 byModel: { model: string; cost: number; tokens: number }[]
102 hourly: number[]
103 // Per model, per local hour of the day, the average API spend by kind of token.
104 hourlyByModel: Record<string, number[][]>
105 unpriced: string[]
106}
107
108// What the machine view draws, worked out once per scan or pick rather than at each drawing: both
109// ranges, the fits, and the API-equivalent cost of each limit hit under the plan in force now.
110export type Machine = {
111 at: number
112 files: number
113 tzOffsetMin: number
114 windows: GlobalScan['windows']
115 views: { '30d': GlobalView; all: GlobalView }
116 fits: Record<string, Fit>
117 hits: Record<string, number[]>
118}
119
120export type ScanStatus = { state: 'idle' | 'scanning' | 'error'; message?: string }
121
122declare module 'claude-code' {
123 interface PluginState {
124 'token-almanac': {
125 since: number
126 totals: Tokens
127 byModel: Record<string, Tokens>
128 turns: TurnRow[]
129 snapshot: Snapshot | null
130 samples: Record<string, Samples>
131 isBandHidden: boolean
132 view: 'session' | 'global'
133 range: '30d' | 'all'
134 machine: Machine | null
135 scanStatus: ScanStatus
136 planChoices: PlanChoice[]
137 details: boolean
138 }
139 }
140}
141