SLOPSHOPPER

Token Almanac

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.

newpanebandcommandprocesstimer
v0.6.1MITupdated 2026-10-10CookPiu/token-almanac
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-almanac
│ ┃ Token usage ✕ › fix the failing auth test and add an audit log call │ ┃ [ This session ] [ This machine ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ 5-hour ─────────────────────────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ $0.42 99k 49% ⏺ Bash(bun test) │ ┃ API-equivalent tokens of 200k context ⎿ 3 pass, 1 fail │ ┃ │ ┃ Makeup by API-equivalent cost ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ███████████████████████████████ │ ┃ ● output 33% ● cache write 38% ● cache rea ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Turns last 1 · peak #1 99k › /token-almanac │ ┃ █ ⎿ token-almanac: Token usage pane opened. │ ┃ │ ┃ Covers this session's 0m, subagents included │ ┃ · API-equivalent at list prices │ ┃ │ ┃ [ Close ] │ ◆ $0.42 · context 49% │ 5-hour 31% [ Details ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ $0.42 · context 49% │ 5-hour 31% [ Details ] [ Hide ]
Pane · Token usage
[ This session ] [ This machine ] 5-hour ──────────────────────────────── 31% $0.42 99k 49% 1 API-equivalent tokens of 200k context turns Makeup by API-equivalent cost ███████████████████████████████ ● output 33% ● cache write 38% ● cache read 20% ● input 9 Turns last 1 · peak #1 99k █ Covers this session's 0m, subagents included · API-equivalent at list prices [ Close ]
README

token-almanac

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.

中文说明

What it shows

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:

ViewContents
This sessionLimit 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 machineLimit 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.

Install

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.

Options

Set them in /config under the plugin:

OptionDefaultMeaning
languageautoauto, en or zh
nodePathnodeThe Node.js used to read transcript history
motiononon 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.

How the estimate works

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:

  • every rise of 5 points or more within a window, and every limit hit (a whole window), is one observation; once a window resets, the observations it was cut into are kept with their exact spend, so the history does not fade;
  • the fit is rise × plan multiple ≈ Σ model factor × Σ token-kind factor × API spend, solved by Levenberg–Marquardt in log parameters with weak priors;
  • a piece that rose well past what its spend says is set aside as use from claude.ai or another device (that use only ever adds to a rise); a piece off the other way is weighed down (Huber);
  • the weekly window, with few pieces of its own, leans on the 5-hour fit's token-kind and model factors once that fit is of medium confidence, while its own size is fitted from its own pieces;
  • token-kind factors capture that limits weigh kinds differently from API prices (cache reads count for much less, so one long session lasts longer than many short ones);
  • you pick your plan in the pane, and each window counts under the plan picked when it began, so windows from different plans share one fit with each plan's multiple fitted rather than taken as sold;
  • windows from before your first pick count under that plan, unless their share per API dollar moved by 1.8× or more for 3 windows or more: such a stretch is fitted as a plan of its own and named after the plan sold at about that multiple (shown as inferred in the estimate details). Changes between plans of about the same size, such as Pro and Team, cannot be told this way.

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.

What it reads, writes and runs

Everything stays on your machine. Nothing is sent anywhere: the mod makes no network requests, and the program it runs makes none either.

  • Reads Claude Code's transcripts under <config>/projects/ to count tokens; message text is parsed only to find turn boundaries and is never stored or sent.
  • Reads Claude Code's language setting and the CLAUDE_CONFIG_DIR, HOME, USERPROFILE, LC_ALL and LANG environment variables.
  • Writes no files itself. Its scanner writes one file of its own, <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.
  • Does not read Claude Code's credentials or any token: the plan comes from your pick in the pane.
  • Runs one program: Node.js (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 are
  • node <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.

Development

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

License

MIT

Source 3 files
hooks/register.tsx 2372 lines
1import { 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[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 const
hooks/i18n.ts 238 lines
1// 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}
238
types/index.d.ts 141 lines
1// 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