KOZMOS Spectra: the context X-ray. The context window broken down by category as a heat grid with a ranked legend, context growth per turn, and an estimate of…

The context X-ray, as a sidebar. /spectra toggles it; autoOpen opens it on start.
It reads $.session.usage({ breakdown: 'summary' }) (a local estimate: no request leaves) when a main turn completes and when the pane opens, never while drawing, and shows:
■ full, □ partial, · free, ░ compaction buffer), SVG squares on the desktop (hover one for its category, tokens and share).turn.complete, as a braille graph (terminal) or an area chart with a dotted projection (desktop).auto-compact in ~N turns, from the least-squares slope of the recent run (the points since the last compaction, up to 7). The threshold is the breakdown's autoCompactThreshold; when the engine gives none, Spectra assumes 95 % of the window and says so. With auto-compact off, the forecast runs against the whole window.Bağlam röntgeni, yan panel olarak. /spectra paneli açar/kapatır; autoOpen oturum başında açar.
$.session.usage({ breakdown: 'summary' }) çağrısını (yerel bir tahmin: istek gitmez) ana tur bittiğinde ve panel açıldığında okur, çizim sırasında asla; şunları gösterir:
turn.complete anındaki bağlam boyutu; terminalde braille grafik, masaüstünde noktalı projeksiyonlu alan grafiği.auto-compact in ~N turns. Eşik, dağılımın autoCompactThreshold değeridir; motor vermezse pencerenin %95'i varsayılır ve bu belirtilir.hooks/register.tsx 352 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SpCategory, SpSnap } from '../types'
5import {
6 Canvas, KZ, brailleGraph, clamp01, fitText, fmtPct, fmtTokens, heat, modelName, pxOf, svg, svgText, xml,
7} from './lib/kz.ts'
8import { ASSUMED_COMPACT, growthSlope, ranked, thresholdOf, toCategories, toGrid, turnsUntil } from './calc.ts'
9
10const PANE = 'kz-spectra'
11const TITLE = 'KOZMOS · Spectra'
12const snapAtom = atom({ plugin: 'spectra', key: 'snap' } as const, null)
13
14const blank = (now: number): SpSnap => ({
15 now,
16 startedAt: now,
17 model: '',
18 window: 0,
19 total: 0,
20 percentage: 0,
21 threshold: 0,
22 thresholdSource: 'assumed',
23 isAutoCompact: true,
24 categories: [],
25 grid: [],
26 growth: [],
27 memoryFiles: 0,
28 mcpTools: 0,
29 hasBreakdown: false,
30 updatedAt: now,
31})
32
33async function isOpen($: EngineInterface): Promise<boolean> {
34 return (await $.ui.panes()).some(p => p.id === PANE)
35}
36
37async function toggle($: EngineInterface): Promise<boolean> {
38 if (await isOpen($)) {
39 await $.ui.close({ id: PANE })
40 return false
41 }
42 await $.ui.open({ id: PANE, title: TITLE })
43 return true
44}
45
46let live: SpSnap = blank(0)
47let lastPublished = ''
48
49async function publish($: EngineInterface): Promise<void> {
50 const key = JSON.stringify({ ...live, now: 0, updatedAt: 0 })
51 if (key === lastPublished) return
52 lastPublished = key
53 await update($, snapAtom, () => JSON.parse(JSON.stringify(live)) as SpSnap)
54}
55
56/**
57 * Reads the breakdown (an estimate, `summary`: no request leaves). With
58 * `record`, also notes the context size this main turn ended at.
59 */
60async function refresh($: EngineInterface, record: boolean): Promise<void> {
61 live.now = await $.clock.now()
62 try {
63 const u = await $.session.usage({ breakdown: 'summary', columns: 120 })
64 live.startedAt = u.startedAt
65 const b = u.context.breakdown
66 if (b) {
67 const window = b.rawMaxTokens || b.maxTokens || u.context.window
68 live.window = window
69 live.total = b.totalTokens
70 live.percentage = b.percentage
71 live.model = b.model
72 live.isAutoCompact = b.isAutoCompactEnabled
73 const t = thresholdOf(b.isAutoCompactEnabled ? b.autoCompactThreshold : window, window)
74 live.threshold = t.tokens
75 live.thresholdSource = b.isAutoCompactEnabled ? t.source : 'engine'
76 live.categories = toCategories(b.categories, window)
77 live.grid = toGrid(b.gridRows, live.categories)
78 live.memoryFiles = b.memoryFiles.length
79 live.mcpTools = b.mcpTools.length
80 live.hasBreakdown = true
81 } else {
82 live.window = u.context.window
83 live.total = u.context.tokens ?? 0
84 live.percentage = u.context.percent ?? 0
85 const t = thresholdOf(undefined, live.window)
86 live.threshold = t.tokens
87 live.thresholdSource = t.source
88 }
89 if (record) {
90 const tokens = u.context.tokens ?? live.total
91 if (tokens > 0) live.growth = [...live.growth, tokens].slice(-48)
92 }
93 } catch {
94 // Keep what we had.
95 }
96 const slope = growthSlope(live.growth)
97 live.slope = slope === undefined ? undefined : Math.round(slope)
98 const current = live.growth[live.growth.length - 1] ?? live.total
99 live.turnsLeft = turnsUntil(current, live.threshold, slope)
100 live.updatedAt = live.now
101 await publish($)
102}
103
104export const register: Register = (on, options) => {
105 on('session.start', async ($, e, next) => {
106 const started = await next(e)
107 const now = await $.clock.now()
108 live = blank(now)
109 lastPublished = ''
110 try {
111 // The snapshot of this same session survives a reload: keep its series.
112 const prev = await read($, snapAtom)
113 const u = await $.session.usage()
114 if (prev && prev.startedAt === u.startedAt) live.growth = prev.growth
115 } catch {
116 // A fresh series.
117 }
118 await $.command.register({ name: 'spectra', description: 'KOZMOS: toggle the Spectra context X-ray sidebar', immediate: true })
119 await refresh($, false).catch(() => undefined)
120 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
121 return started
122 })
123
124 on('command.run', { command: 'spectra' }, async $ => {
125 const opened = await toggle($)
126 if (opened) await refresh($, false).catch(() => undefined)
127 return { text: opened ? 'Spectra open.' : 'Spectra closed.' }
128 })
129
130 on('turn.complete', async ($, e, next) => {
131 const done = await next(e)
132 if (e.agentId === undefined) await refresh($, true).catch(() => undefined)
133 return done
134 }).catch(($, e, next) => next(e))
135
136 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
137 const s = (await read($, snapAtom)) ?? blank(await $.clock.now())
138 const ui = $.ui.resolve(e)
139 const { Box, Text } = ui
140 const order = ranked(s.categories).filter(c => c.kind !== 'deferred')
141
142 if ('Svg' in ui && e.surface !== 'terminal') {
143 const { Svg } = ui
144 const W = pxOf(e.props.bodyColumns, 44)
145 const cards = [gridCard(s, W), legendCard(s, order, W), growthCard(s, W)]
146 return (
147 <Box flexDirection="column">
148 {cards.map((c, i) => <Svg key={`c${i}`} source={c.source} alt={c.alt} width={W} height={c.height} isInteractive={c.isInteractive} />)}
149 </Box>
150 )
151 }
152
153 // ---- terminal ----
154 const cols = Math.max(28, e.props.bodyColumns || 40)
155 const gridW = s.grid[0]?.length ?? 0
156 const cell = gridW > 0 && gridW * 2 <= cols ? 2 : 1
157 const rule = (label: string) => (
158 <Text key={`r-${label}`} color={KZ.mist}>{`── ${label} `}{'─'.repeat(Math.max(0, cols - label.length - 4))}</Text>
159 )
160 const nameW = Math.max(8, Math.min(18, cols - 24))
161 const barW = Math.max(3, cols - nameW - 17)
162 const maxUsed = Math.max(1, ...order.filter(c => c.kind === 'used').map(c => c.tokens))
163 const graphW = Math.max(8, Math.min(cols - 2, 40))
164 const graph = s.growth.length > 1 ? brailleGraph(s.growth, graphW, 2, Math.max(s.threshold, ...s.growth)) : []
165 const fill = s.window > 0 ? s.total / s.window : 0
166
167 return (
168 <Box flexDirection="column">
169 <Box flexDirection="row" justifyContent="space-between">
170 <Text bold color={heat(fill)}>◆ {fmtPct(s.percentage)} context</Text>
171 <Text dimColor>{fmtTokens(s.total)} / {fmtTokens(s.window)}</Text>
172 </Box>
173 <Text dimColor wrap="truncate-end">{modelName(s.model)} · {s.memoryFiles} memory files · {s.mcpTools} MCP tools</Text>
174 {s.grid.length > 0 && 'Raster' in ui
175 ? <ui.Raster key="grid" columns={Math.min(cols, gridW * cell)} rows={s.grid.length} cells={gridRaster(s, cell, Math.min(cols, gridW * cell)).encode()} />
176 : <Text dimColor>{s.hasBreakdown ? '' : 'the breakdown arrives after the first reply'}</Text>}
177 {rule('by category')}
178 {order.map(c => (
179 <Box key={`c-${c.name}`} flexDirection="row">
180 <Text color={c.color}>{c.kind === 'free' ? '·' : c.kind === 'buffer' ? '░' : '■'} </Text>
181 <Box width={nameW} flexShrink={0}><Text wrap="truncate-end" dimColor={c.kind !== 'used'}>{c.name}</Text></Box>
182 <Text wrap="truncate-end">
183 <Text>{fmtTokens(c.tokens).padStart(6)}</Text>
184 <Text dimColor>{fmtPct(c.pct).padStart(5)} </Text>
185 {c.kind === 'used' ? <Text color={c.color}>{'▬'.repeat(Math.max(1, Math.round((c.tokens / maxUsed) * barW)))}</Text> : ''}
186 </Text>
187 </Box>
188 ))}
189 {rule('growth per turn')}
190 {graph.length
191 ? graph.map((line, i) => <Text key={`g${i}`} color={KZ.cyan}>{line}</Text>)
192 : <Text dimColor>a point lands at the end of each turn</Text>}
193 <Text wrap="truncate-end">
194 <Text color={KZ.cyan}>{s.slope !== undefined ? `${s.slope >= 0 ? '+' : '−'}${fmtTokens(Math.abs(s.slope))}/turn` : 'Δ —'}</Text>
195 <Text color={compactColor(s.turnsLeft)}> · {forecastLine(s)}</Text>
196 </Text>
197 <Text dimColor wrap="truncate-end">
198 {s.isAutoCompact
199 ? `compacts at ${fmtTokens(s.threshold)}${s.thresholdSource === 'assumed' ? ` (assumed ${Math.round(ASSUMED_COMPACT * 100)}% of window)` : ''}`
200 : 'auto-compact is off: the window is the wall'}
201 </Text>
202 </Box>
203 )
204 })
205}
206
207const compactColor = (n: number | undefined): string => (n === undefined ? KZ.mist : n <= 3 ? KZ.red : n <= 10 ? KZ.amber : KZ.green)
208
209function forecastLine(s: SpSnap): string {
210 const what = s.isAutoCompact ? 'auto-compact' : 'window full'
211 if (s.turnsLeft === undefined) return s.growth.length < 2 ? 'forecast after two turns' : `${what}: not approaching`
212 if (s.turnsLeft === 0) return `${what} now`
213 return `${what} in ~${s.turnsLeft} turn${s.turnsLeft === 1 ? '' : 's'}`
214}
215
216/** The grid in colored cells: ■ full, □ partial, · free, ░ buffer. */
217function gridRaster(s: SpSnap, cell: number, cols: number): Canvas {
218 const c = new Canvas(cols, Math.max(1, s.grid.length))
219 s.grid.forEach((row, y) => row.forEach(([ci, f], x) => {
220 const cat = s.categories[ci]
221 const px = x * cell
222 if (!cat || cat.kind === 'free') c.set(px, y, '·', '#52525b')
223 else if (cat.kind === 'buffer') c.set(px, y, '░', KZ.amber)
224 else c.set(px, y, f >= 0.7 ? '■' : f > 0 ? '□' : '·', cat.color)
225 }))
226 return c
227}
228
229// ---------------------------------------------------------------------------
230// Desktop.
231
232type Card = { source: string; alt: string; height: number; isInteractive?: boolean }
233
234function gridCard(s: SpSnap, W: number): Card {
235 const pad = 14
236 const gw = s.grid[0]?.length ?? 0
237 const gh = s.grid.length
238 const gap = 3
239 const sq = gw > 0 ? Math.max(6, Math.min(18, Math.floor((W - pad * 2 - gap * (gw - 1)) / gw))) : 0
240 const gridH = gh > 0 ? gh * sq + (gh - 1) * gap : 40
241 const H = 74 + gridH + 14
242 const fill = s.window > 0 ? s.total / s.window : 0
243 const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
244 p.push(`<defs><pattern id="spB" width="4" height="4" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2" height="4" fill="${KZ.amber}" opacity=".55"/></pattern></defs>`)
245 p.push(svgText(pad, 22, 'CONTEXT X-RAY', { cls: 's', size: 10.5, weight: 650 }))
246 p.push(svgText(W - pad, 22, modelName(s.model), { cls: 'm', size: 11, anchor: 'end' }))
247 p.push(svgText(pad, 56, fmtPct(s.percentage), { size: 28, weight: 760, fill: heat(fill) }))
248 p.push(svgText(pad + 76, 50, `${fmtTokens(s.total)} of ${fmtTokens(s.window)}`, { size: 12.5, weight: 600 }))
249 p.push(svgText(pad + 76, 65, `${s.memoryFiles} memory files · ${s.mcpTools} MCP tools`, { cls: 'm', size: 10 }))
250 const y0 = 76
251 if (gh === 0) p.push(svgText(W / 2, y0 + 24, 'the breakdown arrives after the first reply', { cls: 'm', size: 11, anchor: 'middle' }))
252 // The last used square breathes: where the context grows next.
253 let lastUsed: [number, number] | undefined
254 s.grid.forEach((row, y) => row.forEach(([ci, f], x) => {
255 if (s.categories[ci]?.kind === 'used' && f > 0) lastUsed = [x, y]
256 }))
257 s.grid.forEach((row, y) => row.forEach(([ci, f], x) => {
258 const cat = s.categories[ci]
259 const px = pad + x * (sq + gap)
260 const py = y0 + y * (sq + gap)
261 const tip = cat ? `<title>${xml(cat.name)} · ${fmtTokens(cat.tokens)} · ${cat.pct.toFixed(1)}%</title>` : ''
262 const r = Math.min(4, sq / 3).toFixed(1)
263 if (!cat || cat.kind === 'free') p.push(`<rect class="k" x="${px}" y="${py}" width="${sq}" height="${sq}" rx="${r}">${tip}</rect>`)
264 else if (cat.kind === 'buffer') p.push(`<rect x="${px}" y="${py}" width="${sq}" height="${sq}" rx="${r}" fill="url(#spB)">${tip}</rect>`)
265 else {
266 const isLast = lastUsed?.[0] === x && lastUsed[1] === y
267 p.push(`<rect x="${px}" y="${py}" width="${sq}" height="${sq}" rx="${r}" fill="${cat.color}" opacity="${(0.35 + 0.65 * clamp01(f)).toFixed(2)}" ${isLast ? 'class="pulse"' : ''}>${tip}</rect>`)
268 }
269 }))
270 return {
271 source: svg(W, H, p.join('')),
272 height: H,
273 isInteractive: true,
274 alt: `Context ${fmtPct(s.percentage)}: ${fmtTokens(s.total)} of ${fmtTokens(s.window)} tokens`,
275 }
276}
277
278function legendCard(s: SpSnap, order: SpCategory[], W: number): Card {
279 const pad = 14
280 const rowH = 22
281 const H = 34 + Math.max(1, order.length) * rowH + 6
282 const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
283 p.push(svgText(pad, 22, 'BIGGEST FIRST', { cls: 's', size: 10.5, weight: 650 }))
284 const maxUsed = Math.max(1, ...order.filter(c => c.kind === 'used').map(c => c.tokens))
285 const barX = pad + Math.min(170, W * 0.42)
286 const barW = Math.max(30, W - barX - pad - 96)
287 if (order.length === 0) p.push(svgText(pad, 46, 'no breakdown yet', { cls: 'm', size: 11 }))
288 order.forEach((c, i) => {
289 const y = 42 + i * rowH
290 p.push(`<rect x="${pad}" y="${y - 9}" width="10" height="10" rx="3" fill="${c.kind === 'buffer' ? 'url(#spB2)' : c.color}" ${c.kind === 'free' ? 'class="k"' : ''}/>`)
291 p.push(svgText(pad + 16, y, fitText(c.name, 11.5, barX - pad - 22), { size: 11.5, weight: c.kind === 'used' ? 600 : 400, cls: c.kind === 'used' ? 't' : 's' }))
292 if (c.kind === 'used') {
293 const w = Math.max(2, (c.tokens / maxUsed) * barW)
294 p.push(`<rect class="k" x="${barX}" y="${y - 8}" width="${barW}" height="8" rx="4"/>`)
295 p.push(`<rect x="${barX}" y="${y - 8}" width="${w.toFixed(1)}" height="8" rx="4" fill="${c.color}"/>`)
296 }
297 p.push(svgText(W - pad - 44, y, fmtTokens(c.tokens), { size: 11.5, anchor: 'end' }))
298 p.push(svgText(W - pad, y, `${c.pct.toFixed(1)}%`, { cls: 'm', size: 10.5, anchor: 'end' }))
299 })
300 p.unshift(`<defs><pattern id="spB2" width="4" height="4" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2" height="4" fill="${KZ.amber}"/></pattern></defs>`)
301 return {
302 source: svg(W, H, p.join('')),
303 height: H,
304 alt: `Categories: ${order.map(c => `${c.name} ${fmtTokens(c.tokens)}`).join(', ') || 'none yet'}`,
305 }
306}
307
308function growthCard(s: SpSnap, W: number): Card {
309 const pad = 14
310 const H = 150
311 const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
312 p.push(`<defs><linearGradient id="spG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.cyan}" stop-opacity=".45"/><stop offset="1" stop-color="${KZ.cyan}" stop-opacity="0"/></linearGradient></defs>`)
313 p.push(svgText(pad, 22, 'GROWTH PER TURN', { cls: 's', size: 10.5, weight: 650 }))
314 p.push(svgText(W - pad, 22, s.slope !== undefined ? `${s.slope >= 0 ? '+' : '−'}${fmtTokens(Math.abs(s.slope))}/turn` : '', { size: 12, weight: 650, anchor: 'end', fill: KZ.cyan }))
315 const gx = pad
316 const gy = 34
317 const gw = W - pad * 2
318 const gh = 70
319 const series = s.growth
320 const ahead = s.turnsLeft !== undefined ? Math.min(s.turnsLeft, Math.max(3, series.length)) : 0
321 const n = series.length + ahead
322 const top = Math.max(s.threshold, ...series, 1) * 1.04
323 // Only drawn with two points or more, so n - 1 is at least 1.
324 const X = (i: number) => gx + (i / (n - 1)) * gw
325 const Y = (v: number) => gy + gh - (v / top) * gh
326 // The compaction line.
327 const ty = Y(s.threshold)
328 p.push(`<line x1="${gx}" y1="${ty.toFixed(1)}" x2="${gx + gw}" y2="${ty.toFixed(1)}" stroke="${KZ.red}" stroke-width="1" stroke-dasharray="5 4" opacity=".8"/>`)
329 p.push(svgText(gx + gw, ty - 4, `${s.isAutoCompact ? 'compact' : 'window'} ${fmtTokens(s.threshold)}${s.thresholdSource === 'assumed' ? ' (assumed)' : ''}`, { size: 9, anchor: 'end', fill: KZ.red }))
330 if (series.length > 1) {
331 const pts = series.map((v, i) => `${X(i).toFixed(1)},${Y(v).toFixed(1)}`)
332 p.push(`<path d="M${pts.join('L')}L${X(series.length - 1).toFixed(1)},${gy + gh}L${gx},${gy + gh}Z" fill="url(#spG)"/>`)
333 p.push(`<path d="M${pts.join('L')}" stroke="${KZ.cyan}" stroke-width="1.8" fill="none" stroke-linejoin="round"/>`)
334 const lx = X(series.length - 1)
335 const lv = series[series.length - 1]!
336 if (ahead > 0 && s.slope !== undefined) {
337 const ex = X(n - 1)
338 const ev = Math.min(top, lv + s.slope * ahead)
339 p.push(`<path d="M${lx.toFixed(1)},${Y(lv).toFixed(1)}L${ex.toFixed(1)},${Y(ev).toFixed(1)}" stroke="${KZ.cyan}" stroke-width="1.4" stroke-dasharray="2 4" fill="none" class="spdash"/>`)
340 }
341 p.push(`<circle cx="${lx.toFixed(1)}" cy="${Y(lv).toFixed(1)}" r="3.5" fill="${KZ.cyan}" class="pulse"/>`)
342 } else p.push(svgText(gx + gw / 2, gy + gh / 2, 'a point lands at the end of each turn', { cls: 'm', size: 11, anchor: 'middle' }))
343 const c = compactColor(s.turnsLeft)
344 p.push(`<rect x="${pad}" y="${H - 34}" width="${gw}" height="22" rx="11" fill="${c}" opacity=".14"/>`)
345 p.push(svgText(pad + 10, H - 19, forecastLine(s), { size: 11.5, weight: 650, fill: c }))
346 return {
347 source: svg(W, H, p.join(''), '.spdash{animation:spd 1.2s linear infinite}@keyframes spd{to{stroke-dashoffset:-12}}'),
348 height: H,
349 alt: `Context growth: ${forecastLine(s)}`,
350 }
351}
352hooks/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 87 lines1// Spectra math: pure functions, no `$`.
2import { KZ, hue } from './lib/kz.ts'
3import type { SpCategory } from '../types'
4
5/** Fraction of the window assumed for auto-compact when the engine gives no threshold. */
6export const ASSUMED_COMPACT = 0.95
7
8/** A palette color per category, by what it is (kind first, then its name). */
9export function categoryColor(name: string, kind: string, index: number): string {
10 if (kind === 'free') return '#52525b'
11 if (kind === 'buffer') return KZ.amber
12 if (kind === 'deferred') return KZ.mist
13 const n = name.toLowerCase()
14 if (n.includes('system prompt')) return KZ.violet
15 if (n.includes('mcp')) return KZ.magenta
16 if (n.includes('tool')) return KZ.blue
17 if (n.includes('memory')) return KZ.teal
18 if (n.includes('agent')) return KZ.cyan
19 if (n.includes('skill')) return KZ.lime
20 if (n.includes('command')) return KZ.green
21 if (n.includes('message')) return KZ.yellow
22 return hue(0.13 * index + 0.05)
23}
24
25export type RawCategory = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
26
27export function toCategories(raw: readonly RawCategory[], window: number): SpCategory[] {
28 return raw.map((c, i) => ({
29 name: c.name,
30 tokens: c.tokens,
31 kind: c.kind,
32 color: categoryColor(c.name, c.kind, i),
33 pct: window > 0 ? (c.tokens / window) * 100 : 0,
34 }))
35}
36
37/** Used categories by size, then the free space and the buffer; deferred rows last. */
38export function ranked(cats: readonly SpCategory[]): SpCategory[] {
39 const rank = (k: string) => (k === 'used' ? 0 : k === 'free' ? 1 : k === 'buffer' ? 2 : 3)
40 return [...cats].sort((a, b) => rank(a.kind) - rank(b.kind) || b.tokens - a.tokens)
41}
42
43/** The breakdown's grid as [category index, fullness] pairs; -1 for a name not listed. */
44export function toGrid(rows: readonly (readonly { categoryName: string; squareFullness: number; isFilled: boolean }[])[], cats: readonly SpCategory[]): [number, number][][] {
45 return rows.map(r => r.map(sq => [cats.findIndex(c => c.name === sq.categoryName), sq.isFilled ? Math.max(0, Math.min(1, sq.squareFullness)) : 0] as [number, number]))
46}
47
48/** The auto-compact threshold: the engine's, else 95 % of the window. */
49export function thresholdOf(engine: number | undefined, window: number): { tokens: number; source: 'engine' | 'assumed' } {
50 if (engine !== undefined && engine > 0) return { tokens: engine, source: 'engine' }
51 return { tokens: Math.round(window * ASSUMED_COMPACT), source: 'assumed' }
52}
53
54/**
55 * The recent run of the growth series: the points since the last drop (a
56 * compaction or a /clear), at most `k + 1` of them.
57 */
58export function recentRun(series: readonly number[], k = 6): number[] {
59 let start = 0
60 for (let i = 1; i < series.length; i++) if (series[i]! < series[i - 1]! * 0.9) start = i
61 return series.slice(start).slice(-(k + 1))
62}
63
64/** Tokens per turn: least-squares slope over the recent run; needs 2 points. */
65export function growthSlope(series: readonly number[], k = 6): number | undefined {
66 const ys = recentRun(series, k)
67 const n = ys.length
68 if (n < 2) return undefined
69 const mx = (n - 1) / 2
70 const my = ys.reduce((a, b) => a + b, 0) / n
71 let num = 0
72 let den = 0
73 ys.forEach((y, x) => {
74 num += (x - mx) * (y - my)
75 den += (x - mx) * (x - mx)
76 })
77 // Two or more distinct x values: den is above zero.
78 return num / den
79}
80
81/** Whole turns until `current` reaches `threshold` at `slope` per turn. */
82export function turnsUntil(current: number, threshold: number, slope: number | undefined): number | undefined {
83 if (slope === undefined || slope <= 0) return undefined
84 if (current >= threshold) return 0
85 return Math.ceil((threshold - current) / slope)
86}
87types/index.d.ts 46 lines1export type SpCategory = {
2 name: string
3 tokens: number
4 kind: 'used' | 'free' | 'buffer' | 'deferred'
5 /** A palette color the mod chose for the row. */
6 color: string
7 /** Share of the measured window, 0..100. */
8 pct: number
9}
10
11export type SpSnap = {
12 now: number
13 /** The session's start, to tell a reload from a new session. */
14 startedAt: number
15 model: string
16 /** The window the breakdown measured against (may be the compaction window). */
17 window: number
18 /** Tokens in use, by the breakdown's estimate. */
19 total: number
20 percentage: number
21 /** Auto-compact threshold in tokens. */
22 threshold: number
23 /** `engine` when the breakdown gave the threshold, `assumed` for 95 % of the window. */
24 thresholdSource: 'engine' | 'assumed'
25 isAutoCompact: boolean
26 categories: SpCategory[]
27 /** The breakdown's grid, row by row: [category index or -1, fullness 0..1]. */
28 grid: [number, number][][]
29 /** Context tokens at each main-loop turn.complete, oldest first. */
30 growth: number[]
31 /** Tokens added per turn over the recent run. */
32 slope?: number
33 /** Turns left until auto-compact at that slope. */
34 turnsLeft?: number
35 memoryFiles: number
36 mcpTools: number
37 hasBreakdown: boolean
38 updatedAt: number
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 spectra: { snap: SpSnap | null }
44 }
45}
46