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…

The cost lab, as a sidebar. /tokenomics toggles it; set autoOpen to open it on start.
$.session.usage().cost), the burn per hour (last 30 minutes, else the session average) and "at this pace: next hour ≈ $X".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.$.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).
Maliyet laboratuvarı, yan panel olarak. /tokenomics paneli açar/kapatır; autoOpen ile oturum başında açılır.
$.session.usage().cost), saatlik yakım (son 30 dakika, yoksa oturum ortalaması) ve "bu hızla: sonraki saat ≈ $X".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.$.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.
hooks/register.tsx 487 lines1import { 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}
487hooks/lib/kz.ts 510 lines1// 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
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}
510hooks/calc.ts 142 lines1// 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}
142types/index.d.ts 31 lines1export 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