SLOPSHOPPER

spectra

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…

newpanecommand
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/spectra
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spectra
│ ┃ KOZMOS · Spectra ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ 49% context 97k / 200k │ ┃ — · 0 memory files · 0 MCP tools ⏺ Read(src/auth.ts) │ ┃ the breakdown arrives after the first reply ⎿ Read 6 lines │ ┃ ── by category ⏺ Update(src/auth.ts) │ ┃ ───────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ── growth per turn ⏺ Bash(bun test) │ ┃ ───────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ a point lands at the end of each turn │ ┃ Δ — · forecast after two turns ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ compacts at 190k (assumed 95% of window) │ ✻ Worked for 42s · done 4:20 PM │ │ › /spectra │ ⎿ spectra: Spectra open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Spectra
◆ 49% context 97k / 200k — · 0 memory files · 0 MCP tools the breakdown arrives after the first reply ── by category ───────────────────────────────────────── ── growth per turn ───────────────────────────────────── a point lands at the end of each turn Δ — · forecast after two turns compacts at 190k (assumed 95% of window)
README

KOZMOS Spectra

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:

  • The breakdown grid /context draws, as a heatmap: colored cells in a Raster on the terminal (■ full, □ partial, · free, ░ compaction buffer), SVG squares on the desktop (hover one for its category, tokens and share).
  • Categories ranked, biggest first, with tokens, % of the window and a bar.
  • Context growth per turn: the context size at each main turn.complete, as a braille graph (terminal) or an area chart with a dotted projection (desktop).
  • Auto-compact forecast: 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.

Türkçe

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:

  • /context'in çizdiği dağılım ızgarası, ısı haritası olarak: terminalde Raster içinde renkli hücreler, masaüstünde SVG kareler (üzerine gelince kategori, token ve pay).
  • Büyükten küçüğe sıralı kategoriler: token, pencere yüzdesi ve çubuk.
  • Tur başına bağlam büyümesi: her ana turn.complete anındaki bağlam boyutu; terminalde braille grafik, masaüstünde noktalı projeksiyonlu alan grafiği.
  • Otomatik sıkıştırma tahmini: son turların (son sıkıştırmadan beri, en çok 7 nokta) en küçük kareler eğiminden 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.
Source 4 files
hooks/register.tsx 352 lines
1import { 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}
352
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/calc.ts 87 lines
1// 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}
87
types/index.d.ts 46 lines
1export 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