SLOPSHOPPER

tokenomics

KOZMOS Tokenomics: the cost lab. Session cost as the headline, an estimated split by model and by token type, cache hit ratio, burn per hour, cost per turn, a…

newpanecommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/tokenomics
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokenomics
│ ┃ KOZMOS · Tokenomics ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ $0.42 ○ idle │ ┃ 30:00 · $0.84/h · 1 turns · 0 requests ⏺ Read(src/auth.ts) │ ┃ at this pace: next hour ≈ $0.84 ⎿ Read 6 lines │ ┃ ── by model ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ no request priced yet ⏺ Bash(bun test) │ ┃ ── by token type ⎿ 3 pass, 1 fail │ ┃ ─────────────────────────────────────── │ ┃ input $0.00 0 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ output $0.00 0 │ ┃ cache read $0.00 0 ✻ Worked for 42s · done 4:20 PM │ ┃ cache write $0.00 0 │ ┃ cache hit ······················ — › /tokenomics │ ┃ ── per turn ⎿ tokenomics: Tokenomics open. │ ┃ ──────────────────────────────────────────── │ ┃ ▁ last $0.… │ ┃ avg $0.00 · max $0.00 │ ┃ ── 14 days │ ┃ ──────────────────────────────────────────── │ ┃ ─ │ ┃ │ ┃ │ ┃ │ ┃ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ │ ┃ F S S M T W T ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Tokenomics
◆ $0.42 ○ idle 30:00 · $0.84/h · 1 turns · 0 requests at this pace: next hour ≈ $0.84 ── by model ──────────────────────────────────────────── no request priced yet ── by token type ─────────────────────────────────────── input $0.00 0 output $0.00 0 cache read $0.00 0 cache write $0.00 0 cache hit ······················ — ── per turn ──────────────────────────────────────────── ▁ last $0.00 avg $0.00 · max $0.00 ── 14 days ───────────────────────────────────────────── ███ ███ ███ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ▁▁▁ ███ F S S M T W T today $0.42 · 7d $0.42 · 14d $0.42
README

KOZMOS Tokenomics

The cost lab, as a sidebar. /tokenomics toggles it; set autoOpen to open it on start.

  • Headline: the session cost the engine reports ($.session.usage().cost), the burn per hour (last 30 minutes, else the session average) and "at this pace: next hour ≈ $X".
  • By model and by token type: every turn.step response (main loop and subagents) priced with the KOZMOS price table: input, output, cache read, cache write. This split is an estimate; the headline is the engine's figure.
  • Cache hit ratio: cache reads ÷ all input tokens (uncached + cache read + cache write).
  • Cost per turn: a sparkline of each main turn's cost.
  • Days and weeks: the growth of each session's cost is booked into the local day in $.store (days older than 60 are pruned) and drawn as a 14-day bar chart with today, 7-day and 14-day totals.

Terminal: colored rows with eighth-block bars, a sparkline and the 14-day chart in a Raster. Desktop: four SVG cards: the headline with a cost-per-turn area chart, a donut by model, a stacked bar by token type with the cache-hit gauge, and the daily bars (hover a bar for its date and amount).

Türkçe

Maliyet laboratuvarı, yan panel olarak. /tokenomics paneli açar/kapatır; autoOpen ile oturum başında açılır.

  • Manşet: motorun bildirdiği oturum maliyeti ($.session.usage().cost), saatlik yakım (son 30 dakika, yoksa oturum ortalaması) ve "bu hızla: sonraki saat ≈ $X".
  • Modele ve token türüne göre: her turn.step yanıtı (ana döngü ve alt ajanlar) KOZMOS fiyat tablosuyla fiyatlanır: girdi, çıktı, önbellek okuma, önbellek yazma. Bu dağılım bir tahmindir; manşet motorun rakamıdır.
  • Önbellek isabet oranı: önbellek okumaları ÷ tüm girdi tokenları.
  • Tur başına maliyet: her ana turun maliyetinin kıvılcım grafiği.
  • Gün ve hafta: her oturumun maliyet artışı yerel güne $.store içinde işlenir (60 günden eskiler silinir); bugün, 7 ve 14 günlük toplamlarla 14 günlük çubuk grafik çizilir.

Terminal: renkli satırlar, sekizlik blok çubuklar, kıvılcım grafiği ve Raster içinde 14 günlük grafik. Masaüstü: dört SVG kart: tur başı maliyet grafikli manşet, modele göre halka grafik, token türüne göre yığılmış çubuk ve önbellek göstergesi, günlük çubuklar.

Source 4 files
hooks/register.tsx 487 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TokSnap } from '../types'
5import {
6  Canvas, KZ, clamp01, fitText, fmtClock, fmtPct, fmtTokens, fmtUsd, mix, modelName, pxOf, sparkline, svg, svgBar, svgText,
7} from './lib/kz.ts'
8import {
9  TYPES, accrue, addUsage, averagePerHour, cacheHitRatio, costDelta, dayKey, emptyLedger, lastDays, ledgerUsd, ratePerHour,
10  totalOf, weekday,
11} from './calc.ts'
12import type { Days, TypeKey } from './calc.ts'
13
14const PANE = 'kz-tokenomics'
15const TITLE = 'KOZMOS · Tokenomics'
16const snapAtom = atom({ plugin: 'tokenomics', key: 'snap' } as const, null)
17
18const DAYS_KEY = 'days'
19const SEEN_KEY = 'seen'
20
21const MODEL_COLORS = [KZ.violet, KZ.cyan, KZ.magenta, KZ.teal, KZ.amber, KZ.blue, KZ.lime]
22const TYPE_COLOR: Record<TypeKey, string> = { input: KZ.blue, output: KZ.magenta, cacheRead: KZ.green, cacheWrite: KZ.amber }
23const TYPE_LABEL: Record<TypeKey, string> = { input: 'input', output: 'output', cacheRead: 'cache read', cacheWrite: 'cache write' }
24
25const blank = (now: number): TokSnap => ({
26  startedAt: now,
27  now,
28  isWorking: false,
29  ledger: emptyLedger(),
30  turnCosts: [],
31  turns: 0,
32  days: lastDays({}, dayKey(now), 14),
33  weekUsd: 0,
34  todayUsd: 0,
35})
36
37// ---------------------------------------------------------------------------
38// Pane toggle.
39
40async function isOpen($: EngineInterface): Promise<boolean> {
41  return (await $.ui.panes()).some(p => p.id === PANE)
42}
43
44async function toggle($: EngineInterface): Promise<boolean> {
45  if (await isOpen($)) {
46    await $.ui.close({ id: PANE })
47    return false
48  }
49  await $.ui.open({ id: PANE, title: TITLE })
50  return true
51}
52
53// ---------------------------------------------------------------------------
54// Collector. Module state starts over on a reload; session.start refills it
55// from the published snapshot when the session is the same one.
56
57let live: TokSnap = blank(0)
58let samples: [number, number][] = []
59let lastPublished = ''
60let turnStartTotal: number | undefined
61
62/** The best total we have: the engine's, else the estimate. */
63const totalNow = (): number => live.costUsd ?? ledgerUsd(live.ledger)
64
65async function publish($: EngineInterface): Promise<void> {
66  const key = JSON.stringify({ ...live, now: live.isWorking ? live.now : 0 })
67  if (key === lastPublished) return
68  await update($, snapAtom, () => JSON.parse(JSON.stringify(live)) as TokSnap)
69  // Noted once written, so a refused write is tried again on the next sample.
70  lastPublished = key
71}
72
73async function readDays($: EngineInterface): Promise<Days> {
74  try {
75    const v = await $.store.get(DAYS_KEY)
76    return v && typeof v === 'object' ? (v as Days) : {}
77  } catch {
78    return {}
79  }
80}
81
82function applyDays(days: Days, now: number): void {
83  const today = dayKey(now)
84  live.days = lastDays(days, today, 14)
85  live.weekUsd = totalOf(live.days.slice(-7))
86  live.todayUsd = days[today] ?? 0
87}
88
89/** Books the growth of this session's cost into today's total, once per dollar. */
90async function bookCost($: EngineInterface, startedAt: number, total: number, now: number): Promise<void> {
91  const seenRaw = await $.store.get(SEEN_KEY).catch(() => undefined)
92  const seen = (seenRaw && typeof seenRaw === 'object' ? seenRaw : {}) as Record<string, { usd: number; at: number }>
93  const id = String(startedAt)
94  const delta = costDelta(seen[id]?.usd, total)
95  if (delta <= 0) return
96  const days = accrue(await readDays($), dayKey(now), delta)
97  const kept: Record<string, { usd: number; at: number }> = {}
98  for (const [k, v] of Object.entries(seen)) if (v && v.at >= now - 3 * 86_400_000) kept[k] = v
99  kept[id] = { usd: total, at: now }
100  await $.store.set(DAYS_KEY, days)
101  await $.store.set(SEEN_KEY, kept)
102  applyDays(days, now)
103}
104
105async function sample($: EngineInterface): Promise<void> {
106  const now = await $.clock.now()
107  live.now = now
108  try {
109    const u = await $.session.usage()
110    live.startedAt = u.startedAt
111    live.costUsd = u.cost?.usd
112  } catch {
113    // Left as it was.
114  }
115  const total = totalNow()
116  if (total > 0) await bookCost($, live.startedAt, total, now).catch(() => undefined)
117  if (dayKey(now) !== live.days[live.days.length - 1]?.date) applyDays(await readDays($), now)
118  samples.push([now, total])
119  samples = samples.filter(([t]) => t >= now - 60 * 60_000)
120  const avg = averagePerHour(total, live.startedAt, now)
121  const recent = ratePerHour(samples, now)
122  live.avgPerHour = avg === undefined ? undefined : Math.round(avg * 100) / 100
123  live.perHour = recent !== undefined ? Math.round(recent * 100) / 100 : live.avgPerHour
124  await publish($)
125}
126
127export const register: Register = (on, options) => {
128  on('session.start', async ($, e, next) => {
129    const started = await next(e)
130    const now = await $.clock.now()
131    const prev = await read($, snapAtom).catch(() => null)
132    live = blank(now)
133    samples = []
134    lastPublished = ''
135    turnStartTotal = undefined
136    try {
137      const u = await $.session.usage()
138      if (prev && prev.startedAt === u.startedAt) {
139        live.ledger = prev.ledger
140        live.turnCosts = prev.turnCosts
141        live.turns = prev.turns
142      }
143    } catch {
144      // A fresh ledger.
145    }
146    applyDays(await readDays($), now)
147    await $.command.register({ name: 'tokenomics', description: 'KOZMOS: toggle the Tokenomics cost lab sidebar', immediate: true })
148    await sample($).catch(() => undefined)
149    $.clock.every(3000, () => void sample($).catch(() => undefined))
150    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
151    return started
152  })
153
154  on('command.run', { command: 'tokenomics' }, async $ => {
155    const opened = await toggle($)
156    if (opened) await sample($).catch(() => undefined)
157    return { text: opened ? 'Tokenomics open.' : 'Tokenomics closed.' }
158  })
159
160  on('turn.start', async ($, e, next) => {
161    live.isWorking = true
162    turnStartTotal = totalNow()
163    void publish($)
164    return next(e)
165  }).catch(($, e, next) => next(e))
166
167  on('turn.step', async function* ($, e, next) {
168    const r = yield* next(e)
169    if (r.usage) {
170      live.ledger = addUsage(live.ledger, r.usage.model || e.model, r.usage)
171      live.now = await $.clock.now()
172      void publish($)
173    }
174    return r
175  })
176
177  on('turn.complete', async ($, e, next) => {
178    const done = await next(e)
179    if (e.agentId === undefined) {
180      live.isWorking = false
181      try {
182        const u = await $.session.usage()
183        live.costUsd = u.cost?.usd ?? live.costUsd
184      } catch {
185        // Keep the last reading.
186      }
187      const total = totalNow()
188      if (turnStartTotal !== undefined) {
189        live.turnCosts = [...live.turnCosts, Math.max(0, total - turnStartTotal)].slice(-48)
190        live.turns++
191      }
192      turnStartTotal = undefined
193      await sample($).catch(() => undefined)
194    }
195    return done
196  }).catch(($, e, next) => next(e))
197
198  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
199    const s = (await read($, snapAtom)) ?? blank(await $.clock.now())
200
201    if (e.surface !== 'terminal') {
202      const { Box, Svg } = $.ui.resolve(e)
203      const W = pxOf(e.props.bodyColumns, 44)
204      const cards = [headerCard(s, W), modelCard(s, W), typeCard(s, W), dayCard(s, W)]
205      return (
206        <Box flexDirection="column">
207          {cards.map((c, i) => (
208            <Svg key={`c${i}`} source={c.source} alt={c.alt} width={W} height={c.height} isInteractive={c.isInteractive} />
209          ))}
210        </Box>
211      )
212    }
213
214    // ---- terminal ----
215    const { Box, Text, Raster } = $.ui.resolve(e)
216    const cols = Math.max(28, e.props.bodyColumns || 40)
217    const est = ledgerUsd(s.ledger)
218    const total = s.costUsd ?? est
219    const elapsed = s.now - s.startedAt
220    const labelW = 12
221    const barW = Math.max(4, Math.min(22, cols - labelW - 14))
222    const rule = (label: string) => (
223      <Text key={`r-${label}`} color={KZ.mist}>{`── ${label} `}{'─'.repeat(Math.max(0, cols - label.length - 4))}</Text>
224    )
225    const hit = cacheHitRatio(s.ledger.tokens)
226    const lastTurn = s.turnCosts[s.turnCosts.length - 1]
227    const avgTurn = s.turnCosts.length ? totalOf(s.turnCosts.map(usd => ({ usd }))) / s.turnCosts.length : undefined
228    const maxModel = Math.max(1e-9, ...s.ledger.byModel.map(m => m.usd))
229    const maxType = Math.max(1e-9, ...TYPES.map(k => s.ledger.usd[k]))
230
231    return (
232      <Box flexDirection="column">
233        <Box flexDirection="row" justifyContent="space-between">
234          <Text bold color={KZ.yellow}>◆ {fmtUsd(total)}{s.costUsd === undefined ? <Text color={KZ.mist}> est.</Text> : ''}</Text>
235          <Text color={s.isWorking ? KZ.green : KZ.mist}>{s.isWorking ? '● burning' : '○ idle'}</Text>
236        </Box>
237        <Text wrap="truncate-end" dimColor>
238          {fmtClock(elapsed)} · {s.perHour !== undefined ? `${fmtUsd(s.perHour)}/h` : '—/h'} · {s.turns} turns · {s.ledger.steps} requests
239        </Text>
240        <Text wrap="truncate-end">
241          <Text color={KZ.mist}>at this pace: </Text>
242          <Text color={KZ.amber}>{s.perHour !== undefined ? `next hour ≈ ${fmtUsd(s.perHour)}` : 'learning the pace…'}</Text>
243        </Text>
244        {rule('by model')}
245        {s.ledger.byModel.length === 0 && <Text dimColor>no request priced yet</Text>}
246        {s.ledger.byModel.slice(0, 5).map((m, i) => (
247          <Box key={`m-${m.model}`} flexDirection="row">
248            <Box width={labelW} flexShrink={0}><Text color={MODEL_COLORS[i % MODEL_COLORS.length]} wrap="truncate-end">{modelName(m.model)}</Text></Box>
249            <Text wrap="truncate-end">
250              <Text color={MODEL_COLORS[i % MODEL_COLORS.length]}>{barOf(m.usd / maxModel, barW)}</Text>
251              <Text> {fmtUsd(m.usd)}</Text>
252              <Text dimColor> {fmtPct(est > 0 ? (m.usd / est) * 100 : 0)}</Text>
253            </Text>
254          </Box>
255        ))}
256        {rule('by token type')}
257        {TYPES.map(k => (
258          <Box key={`t-${k}`} flexDirection="row">
259            <Box width={labelW} flexShrink={0}><Text color={TYPE_COLOR[k]}>{TYPE_LABEL[k]}</Text></Box>
260            <Text wrap="truncate-end">
261              <Text color={TYPE_COLOR[k]}>{barOf(s.ledger.usd[k] / maxType, barW)}</Text>
262              <Text> {fmtUsd(s.ledger.usd[k])}</Text>
263              <Text dimColor> {fmtTokens(s.ledger.tokens[k])}</Text>
264            </Text>
265          </Box>
266        ))}
267        <Box flexDirection="row">
268          <Box width={labelW} flexShrink={0}><Text color={KZ.green}>cache hit</Text></Box>
269          <Text wrap="truncate-end">
270            <Text color={mix(KZ.red, KZ.green, hit ?? 0)}>{barOf(hit ?? 0, barW, '·')}</Text>
271            <Text bold> {hit === undefined ? '—' : fmtPct(hit * 100)}</Text>
272          </Text>
273        </Box>
274        {rule('per turn')}
275        <Text wrap="truncate-end">
276          <Text color={KZ.violet}>{s.turnCosts.length ? sparkline(s.turnCosts, Math.max(6, cols - 22)) : '·'.repeat(Math.max(6, cols - 22))}</Text>
277          <Text dimColor> {lastTurn !== undefined ? `last ${fmtUsd(lastTurn)}` : 'no turn yet'}</Text>
278        </Text>
279        {avgTurn !== undefined && <Text dimColor>avg {fmtUsd(avgTurn)} · max {fmtUsd(Math.max(...s.turnCosts))}</Text>}
280        {rule('14 days')}
281        <Raster key="days" columns={cols} rows={5} cells={dayRaster(s, cols).encode()} />
282        <Text wrap="truncate-end">
283          <Text color={KZ.violet}>today {fmtUsd(s.todayUsd)}</Text>
284          <Text dimColor> · 7d {fmtUsd(s.weekUsd)} · 14d {fmtUsd(totalOf(s.days))}</Text>
285        </Text>
286      </Box>
287    )
288  })
289}
290
291/** An eighth-block bar with a custom empty glyph. */
292function barOf(ratio: number, width: number, empty = ' '): string {
293  const w = Math.max(1, Math.floor(width))
294  const exact = clamp01(ratio) * w
295  const full = Math.floor(exact)
296  // No glyph for a remainder under an eighth: charAt(-1) is ''.
297  const part = '▏▎▍▌▋▊▉'.charAt(Math.floor((exact - full) * 8) - 1)
298  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - full - (part ? 1 : 0)))
299}
300
301/** 14 vertical bars in eighth blocks, four rows tall, weekday initials under. */
302function dayRaster(s: TokSnap, cols: number): Canvas {
303  const c = new Canvas(cols, 5)
304  const n = s.days.length
305  const bw = Math.max(1, Math.min(3, Math.floor((cols - (n - 1)) / n)))
306  const used = n * bw + (n - 1)
307  const x0 = Math.max(0, Math.floor((cols - used) / 2))
308  const max = Math.max(1e-9, ...s.days.map(d => d.usd))
309  const levels = 4 * 8
310  const V = ' ▁▂▃▄▅▆▇█'
311  s.days.forEach((d, i) => {
312    const isToday = i === n - 1
313    const h = d.usd > 0 ? Math.max(1, Math.round((d.usd / max) * levels)) : 0
314    const color = isToday ? KZ.violet : mix(KZ.teal, KZ.magenta, d.usd / max)
315    const x = x0 + i * (bw + 1)
316    for (let row = 0; row < 4; row++) {
317      const fromBottom = 3 - row
318      const fill = Math.max(0, Math.min(8, h - fromBottom * 8))
319      const ch = h === 0 && fromBottom === 0 ? '▁' : V.charAt(fill)
320      for (let k = 0; k < bw; k++) c.set(x + k, row, ch, h === 0 ? '#3f3f46' : color)
321    }
322    const wd = weekday(d.date).charAt(0)
323    c.set(x + Math.floor((bw - 1) / 2), 4, wd, isToday ? KZ.violet : KZ.mist)
324  })
325  return c
326}
327
328// ---------------------------------------------------------------------------
329// Desktop cards: one Svg per section.
330
331type Card = { source: string; alt: string; height: number; isInteractive?: boolean }
332
333function headerCard(s: TokSnap, W: number): Card {
334  const pad = 14
335  const H = 112
336  const total = s.costUsd ?? ledgerUsd(s.ledger)
337  const p: string[] = []
338  p.push(`<defs><linearGradient id="tkG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.yellow}" stop-opacity=".45"/><stop offset="1" stop-color="${KZ.yellow}" stop-opacity="0"/></linearGradient></defs>`)
339  p.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`)
340  p.push(svgText(pad, 22, 'SESSION COST', { cls: 's', size: 10.5, weight: 650 }))
341  p.push(`<circle cx="${pad + 92}" cy="18" r="4" fill="${s.isWorking ? KZ.green : KZ.mist}" class="${s.isWorking ? 'pulse' : ''}"/>`)
342  p.push(svgText(pad, 54, fmtUsd(total), { size: 30, weight: 760, fill: KZ.yellow }))
343  if (s.costUsd === undefined) p.push(svgText(pad + 4 + fmtUsd(total).length * 17, 54, 'est.', { cls: 'm', size: 11 }))
344  p.push(svgText(W - pad, 24, s.perHour !== undefined ? `${fmtUsd(s.perHour)}/h` : '—/h', { size: 15, weight: 700, anchor: 'end' }))
345  p.push(svgText(W - pad, 41, s.perHour !== undefined ? `next hour ≈ ${fmtUsd(s.perHour)}` : 'learning the pace…', { cls: 's', size: 10.5, anchor: 'end' }))
346  p.push(svgText(W - pad, 56, `${fmtClock(s.now - s.startedAt)} · ${s.turns} turns`, { cls: 'm', size: 10, anchor: 'end' }))
347  // Cost per turn, as an area chart along the bottom.
348  const gx = pad
349  const gy = 68
350  const gw = W - pad * 2
351  const gh = 32
352  const vals = s.turnCosts.slice(-40)
353  if (vals.length > 1) {
354    const max = Math.max(1e-9, ...vals)
355    const step = gw / (vals.length - 1)
356    const pts = vals.map((v, i) => `${(gx + i * step).toFixed(1)},${(gy + gh - (v / max) * gh).toFixed(1)}`)
357    p.push(`<path d="M${pts.join('L')}L${gx + gw},${gy + gh}L${gx},${gy + gh}Z" fill="url(#tkG)"/>`)
358    p.push(`<path d="M${pts.join('L')}" stroke="${KZ.yellow}" stroke-width="1.6" fill="none" stroke-linejoin="round"/>`)
359    const last = pts[pts.length - 1]!.split(',')
360    p.push(`<circle cx="${last[0]}" cy="${last[1]}" r="3" fill="${KZ.yellow}" class="pulse"/>`)
361    p.push(svgText(gx, gy + 8, `per turn · max ${fmtUsd(max)}`, { cls: 'm', size: 9 }))
362  } else {
363    p.push(`<line class="ln" x1="${gx}" y1="${gy + gh}" x2="${gx + gw}" y2="${gy + gh}" stroke-dasharray="3 4"/>`)
364    p.push(svgText(gx, gy + gh - 6, 'cost per turn appears after two turns', { cls: 'm', size: 10 }))
365  }
366  return {
367    source: svg(W, H, p.join('')),
368    height: H,
369    alt: `Session cost ${fmtUsd(total)}; ${s.perHour !== undefined ? fmtUsd(s.perHour) + ' per hour' : 'pace unknown'}; ${s.turns} turns`,
370  }
371}
372
373function modelCard(s: TokSnap, W: number): Card {
374  const pad = 14
375  const rows = s.ledger.byModel.slice(0, 6)
376  const H = Math.max(120, 44 + rows.length * 20)
377  const est = ledgerUsd(s.ledger)
378  const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
379  p.push(svgText(pad, 22, 'BY MODEL · estimated', { cls: 's', size: 10.5, weight: 650 }))
380  const r = 34
381  const cx = pad + r + 4
382  const cy = 30 + r + 8
383  const C = 2 * Math.PI * r
384  p.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="12" class="ln" style="stroke-opacity:.9"/>`)
385  let acc = 0
386  rows.forEach((m, i) => {
387    const frac = est > 0 ? m.usd / est : 0
388    if (frac <= 0) return
389    const len = Math.max(0.5, frac * C - (rows.length > 1 ? 1.5 : 0))
390    p.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${MODEL_COLORS[i % MODEL_COLORS.length]}" stroke-width="12" stroke-dasharray="${len.toFixed(2)} ${(C - len).toFixed(2)}" stroke-dashoffset="${(-acc * C).toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"/>`)
391    acc += frac
392  })
393  if (s.isWorking) p.push(`<circle cx="${cx}" cy="${cy}" r="${r + 10}" fill="none" stroke="${KZ.violet}" stroke-width="1" stroke-dasharray="2 6" opacity=".6" class="spin"/>`)
394  p.push(svgText(cx, cy + 2, fmtUsd(est), { size: 13, weight: 720, anchor: 'middle' }))
395  p.push(svgText(cx, cy + 15, `${s.ledger.steps} req`, { cls: 'm', size: 9, anchor: 'middle' }))
396  const lx = cx + r + 24
397  if (rows.length === 0) p.push(svgText(lx, cy + 4, 'no request priced yet', { cls: 'm', size: 11 }))
398  rows.forEach((m, i) => {
399    const y = 44 + i * 20
400    const c = MODEL_COLORS[i % MODEL_COLORS.length]
401    p.push(`<rect x="${lx}" y="${y - 9}" width="10" height="10" rx="3" fill="${c}"/>`)
402    p.push(svgText(lx + 16, y, fitText(modelName(m.model), 12, W - lx - 120), { size: 12, weight: 600 }))
403    p.push(svgText(W - pad - 40, y, fmtUsd(m.usd), { size: 12, anchor: 'end' }))
404    p.push(svgText(W - pad, y, fmtPct(est > 0 ? (m.usd / est) * 100 : 0), { cls: 'm', size: 11, anchor: 'end' }))
405  })
406  return {
407    source: svg(W, H, p.join('')),
408    height: H,
409    alt: `By model: ${rows.map(m => `${modelName(m.model)} ${fmtUsd(m.usd)}`).join(', ') || 'nothing yet'}`,
410  }
411}
412
413function typeCard(s: TokSnap, W: number): Card {
414  const pad = 14
415  const H = 182
416  const est = ledgerUsd(s.ledger)
417  const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
418  p.push(svgText(pad, 22, 'BY TOKEN TYPE', { cls: 's', size: 10.5, weight: 650 }))
419  const bw = W - pad * 2
420  p.push(`<defs><clipPath id="tkC"><rect x="${pad}" y="34" width="${bw}" height="14" rx="7"/></clipPath></defs>`)
421  p.push(`<rect class="k" x="${pad}" y="34" width="${bw}" height="14" rx="7"/>`)
422  let x = pad
423  p.push('<g clip-path="url(#tkC)">')
424  for (const k of TYPES) {
425    const w = est > 0 ? (s.ledger.usd[k] / est) * bw : 0
426    if (w > 0) p.push(`<rect x="${x.toFixed(1)}" y="34" width="${w.toFixed(1)}" height="14" fill="${TYPE_COLOR[k]}"/>`)
427    x += w
428  }
429  p.push('</g>')
430  TYPES.forEach((k, i) => {
431    const y = 72 + i * 19
432    p.push(`<circle cx="${pad + 5}" cy="${y - 4}" r="4.5" fill="${TYPE_COLOR[k]}"/>`)
433    p.push(svgText(pad + 16, y, TYPE_LABEL[k], { size: 11.5, weight: 600 }))
434    p.push(svgText(W - pad - 110, y, fmtTokens(s.ledger.tokens[k]), { cls: 'm', size: 11, anchor: 'end' }))
435    p.push(svgText(W - pad - 44, y, fmtUsd(s.ledger.usd[k]), { size: 11.5, anchor: 'end' }))
436    p.push(svgText(W - pad, y, fmtPct(est > 0 ? (s.ledger.usd[k] / est) * 100 : 0), { cls: 'm', size: 11, anchor: 'end' }))
437  })
438  const hit = cacheHitRatio(s.ledger.tokens)
439  const hy = 158
440  p.push(svgText(pad, hy, 'CACHE HIT', { cls: 's', size: 10, weight: 650 }))
441  p.push(svgBar(pad + 72, hy - 8, bw - 72 - 46, 9, hit ?? 0, mix(KZ.red, KZ.green, hit ?? 0)))
442  p.push(svgText(W - pad, hy, hit === undefined ? '—' : fmtPct(hit * 100), { size: 13, weight: 720, anchor: 'end', fill: mix(KZ.red, KZ.green, hit ?? 0) }))
443  p.push(svgText(pad + 72, hy + 15, 'cache reads ÷ all input tokens', { cls: 'm', size: 9 }))
444  return {
445    source: svg(W, H, p.join('')),
446    height: H,
447    alt: `By token type: ${TYPES.map(k => `${TYPE_LABEL[k]} ${fmtUsd(s.ledger.usd[k])}`).join(', ')}; cache hit ${hit === undefined ? 'unknown' : fmtPct(hit * 100)}`,
448  }
449}
450
451function dayCard(s: TokSnap, W: number): Card {
452  const pad = 14
453  const H = 150
454  const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
455  p.push(`<defs><linearGradient id="tkD" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.violet}"/><stop offset="1" stop-color="${KZ.magenta}"/></linearGradient></defs>`)
456  p.push(svgText(pad, 22, 'LAST 14 DAYS', { cls: 's', size: 10.5, weight: 650 }))
457  p.push(svgText(W - pad, 22, `7d ${fmtUsd(s.weekUsd)} · today ${fmtUsd(s.todayUsd)}`, { size: 11.5, weight: 650, anchor: 'end' }))
458  const n = s.days.length
459  const gx = pad
460  const gy = 36
461  const gw = W - pad * 2
462  const gh = 82
463  const slot = gw / n
464  const bw = Math.max(4, slot * 0.64)
465  const max = Math.max(1e-9, ...s.days.map(d => d.usd))
466  const avg = totalOf(s.days) / n
467  s.days.forEach((d, i) => {
468    const isToday = i === n - 1
469    const h = d.usd > 0 ? Math.max(3, (d.usd / max) * gh) : 2
470    const x = gx + i * slot + (slot - bw) / 2
471    const fill = isToday ? 'url(#tkD)' : mix(KZ.teal, KZ.violet, d.usd / max)
472    p.push(`<rect x="${x.toFixed(1)}" y="${(gy + gh - h).toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="${Math.min(4, bw / 2).toFixed(1)}" fill="${d.usd > 0 ? fill : 'none'}" ${d.usd > 0 ? '' : 'class="k"'} ${isToday && s.isWorking ? 'class="pulse"' : ''}><title>${d.date} · ${fmtUsd(d.usd)}</title></rect>`)
473    p.push(svgText(x + bw / 2, gy + gh + 14, weekday(d.date).charAt(0), { cls: isToday ? 't' : 'm', size: 9.5, weight: isToday ? 700 : 400, anchor: 'middle' }))
474  })
475  if (avg > 0) {
476    const ay = gy + gh - (avg / max) * gh
477    p.push(`<line x1="${gx}" y1="${ay.toFixed(1)}" x2="${gx + gw}" y2="${ay.toFixed(1)}" stroke="${KZ.amber}" stroke-width="1" stroke-dasharray="4 4" opacity=".8"/>`)
478    p.push(svgText(gx + gw, ay - 3, `avg ${fmtUsd(avg)}`, { size: 9, anchor: 'end', fill: KZ.amber }))
479  } else p.push(svgText(gx + gw / 2, gy + gh / 2, 'days fill in as sessions spend', { cls: 'm', size: 11, anchor: 'middle' }))
480  return {
481    source: svg(W, H, p.join('')),
482    height: H,
483    isInteractive: true,
484    alt: `Last 14 days: ${fmtUsd(totalOf(s.days))}; last 7 days ${fmtUsd(s.weekUsd)}; today ${fmtUsd(s.todayUsd)}`,
485  }
486}
487
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/calc.ts 142 lines
1// Tokenomics math: pure functions, no `$`. The register module feeds them.
2import { priceOf } from './lib/kz.ts'
3import type { Usage } from './lib/kz.ts'
4import type { TokByType, TokLedger, TokModelRow } from '../types'
5
6export const TYPES = ['input', 'output', 'cacheRead', 'cacheWrite'] as const
7export type TypeKey = (typeof TYPES)[number]
8export type ByType = TokByType
9
10export const zeroByType = (): ByType => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
11
12/** One request's tokens and estimated dollars, by token type. */
13export function splitOf(model: string, u: Usage | null | undefined): { usd: ByType; tokens: ByType } {
14  const tokens: ByType = {
15    input: u?.input_tokens ?? 0,
16    output: u?.output_tokens ?? 0,
17    cacheRead: u?.cache_read_input_tokens ?? 0,
18    cacheWrite: u?.cache_creation_input_tokens ?? 0,
19  }
20  const [i, o, r, w] = priceOf(model)
21  const usd: ByType = {
22    input: (tokens.input * i) / 1e6,
23    output: (tokens.output * o) / 1e6,
24    cacheRead: (tokens.cacheRead * r) / 1e6,
25    cacheWrite: (tokens.cacheWrite * w) / 1e6,
26  }
27  return { usd, tokens }
28}
29
30export type ModelRow = TokModelRow
31
32export type Ledger = TokLedger
33
34export const emptyLedger = (): Ledger => ({ byModel: [], usd: zeroByType(), tokens: zeroByType(), steps: 0 })
35
36const sum = (t: ByType): number => t.input + t.output + t.cacheRead + t.cacheWrite
37
38/** Adds one request's usage to the running ledger (a new object). */
39export function addUsage(l: Ledger, model: string, u: Usage | null | undefined): Ledger {
40  if (!u) return l
41  const s = splitOf(model, u)
42  const usd = zeroByType()
43  const tokens = zeroByType()
44  for (const k of TYPES) {
45    usd[k] = l.usd[k] + s.usd[k]
46    tokens[k] = l.tokens[k] + s.tokens[k]
47  }
48  const key = model || 'unknown'
49  const rows = l.byModel.map(r => ({ ...r }))
50  const row = rows.find(r => r.model === key)
51  if (row) {
52    row.usd += sum(s.usd)
53    row.tokens += sum(s.tokens)
54    row.steps++
55  } else rows.push({ model: key, usd: sum(s.usd), tokens: sum(s.tokens), steps: 1 })
56  rows.sort((a, b) => b.usd - a.usd)
57  return { byModel: rows, usd, tokens, steps: l.steps + 1 }
58}
59
60export const ledgerUsd = (l: Ledger): number => sum(l.usd)
61
62/** Cache reads over everything read as input (uncached + cache read + cache write). */
63export function cacheHitRatio(t: ByType): number | undefined {
64  const all = t.input + t.cacheRead + t.cacheWrite
65  return all > 0 ? t.cacheRead / all : undefined
66}
67
68/** Dollars per hour from (time, cumulative usd) samples over the last `windowMs`. */
69export function ratePerHour(samples: readonly (readonly [number, number])[], now: number, windowMs = 30 * 60_000, minSpanMs = 5 * 60_000): number | undefined {
70  const recent = samples.filter(([t]) => t >= now - windowMs)
71  const first = recent[0]
72  const last = recent[recent.length - 1]
73  if (!first || !last) return undefined
74  const span = last[0] - first[0]
75  if (span < minSpanMs) return undefined
76  return Math.max(0, ((last[1] - first[1]) / span) * 3600_000)
77}
78
79/** Session-average dollars per hour, from the start. */
80export function averagePerHour(usd: number | undefined, startedAt: number, now: number): number | undefined {
81  const span = now - startedAt
82  if (usd === undefined || span < 60_000) return undefined
83  return usd / (span / 3600_000)
84}
85
86/** How much of the cost is new since the last reading; a drop means the counter started over. */
87export function costDelta(prev: number | undefined, cur: number): number {
88  if (!Number.isFinite(cur) || cur <= 0) return 0
89  if (prev === undefined || cur < prev) return cur
90  return cur - prev
91}
92
93// ---------------------------------------------------------------------------
94// Local calendar days.
95
96const pad2 = (n: number): string => String(n).padStart(2, '0')
97
98export function dayKey(ms: number): string {
99  const d = new Date(ms)
100  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
101}
102
103export function parseDay(key: string): Date {
104  // split always yields the year; month and day may be missing.
105  const [y, m, d] = key.split('-').map(Number) as [number, number?, number?]
106  return new Date(y, (m ?? 1) - 1, d ?? 1, 12)
107}
108
109export function addDays(key: string, n: number): string {
110  const d = parseDay(key)
111  d.setDate(d.getDate() + n)
112  return dayKey(d.getTime())
113}
114
115export type Days = Record<string, number>
116
117/** Adds `usd` to day `key` and drops days older than `keep` days before it. */
118export function accrue(days: Days, key: string, usd: number, keep = 60): Days {
119  const out: Days = {}
120  const oldest = addDays(key, -(keep - 1))
121  for (const [k, v] of Object.entries(days)) if (k >= oldest && Number.isFinite(v)) out[k] = v
122  if (usd > 0) out[key] = (out[key] ?? 0) + usd
123  return out
124}
125
126/** The last `n` days ending today, oldest first, zero-filled. */
127export function lastDays(days: Days, today: string, n: number): { date: string; usd: number }[] {
128  return Array.from({ length: n }, (_, i) => {
129    const date = addDays(today, i - (n - 1))
130    return { date, usd: days[date] ?? 0 }
131  })
132}
133
134export function totalOf(rows: readonly { usd: number }[]): number {
135  return rows.reduce((a, r) => a + r.usd, 0)
136}
137
138const WD = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
139export function weekday(key: string): string {
140  return WD[parseDay(key).getDay()] ?? ''
141}
142
types/index.d.ts 31 lines
1export type TokTypeKey = 'input' | 'output' | 'cacheRead' | 'cacheWrite'
2export type TokByType = Record<TokTypeKey, number>
3export type TokModelRow = { model: string; usd: number; tokens: number; steps: number }
4export type TokLedger = { byModel: TokModelRow[]; usd: TokByType; tokens: TokByType; steps: number }
5
6export type TokSnap = {
7  /** The engine's session total, when the host keeps a ledger. */
8  costUsd?: number
9  startedAt: number
10  now: number
11  isWorking: boolean
12  /** Estimated from every turn.step usage, main loop and subagents. */
13  ledger: TokLedger
14  /** Cost of each finished main-loop turn, oldest first (last 48). */
15  turnCosts: number[]
16  turns: number
17  /** Recent burn (last 30 min), else the session average. */
18  perHour?: number
19  avgPerHour?: number
20  /** The last 14 local days, oldest first, today last. */
21  days: { date: string; usd: number }[]
22  weekUsd: number
23  todayUsd: number
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    tokenomics: { snap: TokSnap | null }
29  }
30}
31