KOZMOS Almanac: what this session is made of. Slash commands run, skills invoked, subagent types spawned, MCP servers used, models seen, plugins whose tools…

What this session is made of, in one pane.
Almanac keeps a ledger as the session runs and shows each part with how often it was used and when it first appeared (time since the session began):
mcp__<server>__… tools were called.The header carries the Claude Code version, the session's age, totals, and how many installed slash commands this session never ran. On the desktop each section is one SVG (ranked bars, pill clouds sized by use, light and dark aware); on the terminal they are colored bars and wrapped tag clouds.
/almanac toggles the pane, /almanac print writes the ledger as text. autoOpen (default off) opens it at start.
Bu oturumun nelerden oluştuğu, tek bir panelde.
Almanac oturum boyunca bir defter tutar ve her parçayı ne sıklıkla kullanıldığı ve ilk ne zaman göründüğüyle (oturumun başından beri geçen süre) gösterir: çalıştırılan eğik çizgi komutları, Skill aracıyla çağrılan beceriler (etiket bulutu), başlatılan alt ajan türleri, araçları çağrılan MCP sunucuları, görülen modeller ve istek sayıları, araçları kullanılan eklentiler ve araç aileleri (tek yığılmış çubuk ve açıklama).
Başlıkta Claude Code sürümü, oturumun yaşı, toplamlar ve bu oturumda hiç çalıştırılmayan kurulu komut sayısı yer alır. Masaüstünde her bölüm tek bir SVG'dir (açık ve koyu temaya uyar); terminalde renkli çubuklar ve sarılan etiket bulutlarıdır.
/almanac paneli açıp kapatır, /almanac print defteri metin olarak yazar. autoOpen (varsayılan kapalı) paneli açılışta açar.
hooks/register.tsx 286 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AlmanacEntry, AlmanacSnap } from '../types'
5import { KZ, bar, fmtClock, modelName, pxOf } from './lib/kz.ts'
6import { FAMILY_COLOR, Tally, barsSvg, cloudSvg, familiesSvg, familyOf, headerSvg, mcpParts, pluginOfAgent, snapText, tagColor } from './book.ts'
7import type { Family } from './book.ts'
8
9const PANE = 'kz-almanac'
10const TITLE = 'KOZMOS · Almanac'
11const snapAtom = atom({ plugin: 'almanac', key: 'snap' } as const, null)
12
13// The ledger. Module state starts over on a reload; session.start resets it.
14let startedAt = 0
15let version = ''
16let toolCount = 0
17let commands = new Tally()
18let skills = new Tally()
19let agents = new Tally()
20let mcp = new Tally()
21let models = new Tally()
22let plugins = new Tally()
23let families = new Tally()
24let installed: string[] | null = null
25let ownTools = new Set<string>()
26let isDirty = true
27let lastKey = ''
28let tick = 0
29
30function reset(now: number): void {
31 startedAt = now
32 toolCount = 0
33 commands = new Tally()
34 skills = new Tally()
35 agents = new Tally()
36 mcp = new Tally()
37 models = new Tally()
38 plugins = new Tally()
39 families = new Tally()
40 installed = null
41 ownTools = new Set()
42 isDirty = true
43 lastKey = ''
44 tick = 0
45}
46
47function snapshot(now: number): AlmanacSnap {
48 const unused = installed ? installed.filter(n => !commands.has(n)).length : -1
49 return {
50 version,
51 startedAt,
52 now,
53 commands: commands.entries(startedAt),
54 skills: skills.entries(startedAt),
55 agents: agents.entries(startedAt),
56 mcp: mcp.entries(startedAt),
57 models: models.entries(startedAt),
58 plugins: plugins.entries(startedAt),
59 families: families.entries(startedAt),
60 tools: toolCount,
61 installed: installed ? installed.length : -1,
62 unused,
63 }
64}
65
66async function publish($: EngineInterface, force: boolean): Promise<void> {
67 if (!isDirty && !force) return
68 isDirty = false
69 const now = await $.clock.now()
70 const snap = snapshot(now)
71 // The clock alone does not redraw: only a change in what was used does,
72 // with a minute's step so the session age stays roughly right.
73 const key = JSON.stringify({ ...snap, now: Math.floor((now - startedAt) / 60_000) })
74 if (key === lastKey) return
75 lastKey = key
76 await update($, snapAtom, () => snap)
77}
78
79async function readCatalog($: EngineInterface): Promise<void> {
80 try {
81 installed = (await $.command.list()).map(c => c.name)
82 isDirty = true
83 } catch {
84 // Unknown stays unknown.
85 }
86 try {
87 ownTools = new Set((await $.tool.list()).filter(t => !t.mcp && t.name.startsWith('mcp__')).map(t => t.name))
88 } catch {
89 // Every mcp__ name then reads as an MCP server.
90 }
91}
92
93async function toggle($: EngineInterface): Promise<boolean> {
94 if ((await $.ui.panes()).some(p => p.id === PANE)) {
95 await $.ui.close({ id: PANE })
96 return false
97 }
98 await readCatalog($)
99 await publish($, true)
100 await $.ui.open({ id: PANE, title: TITLE })
101 return true
102}
103
104async function heartbeat($: EngineInterface): Promise<void> {
105 tick++
106 if (tick % 30 === 0) await readCatalog($)
107 await publish($, tick % 60 === 0)
108}
109
110const familyLabel = (e: AlmanacEntry) => e.name
111
112export const register: Register = (on, options) => {
113 on('session.start', async ($, e, next) => {
114 const started = await next(e)
115 reset(await $.clock.now())
116 await $.command.register({
117 name: 'almanac',
118 description: 'KOZMOS: toggle the Almanac of what this session is made of (/almanac print writes it as text)',
119 argumentHint: '[print]',
120 immediate: true,
121 })
122 try {
123 version = (await $.session.version()).version
124 } catch {
125 version = ''
126 }
127 await readCatalog($)
128 await publish($, true).catch(() => undefined)
129 $.clock.every(1000, () => void heartbeat($).catch(() => undefined))
130 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
131 return started
132 })
133
134 on('command.run', { command: 'almanac' }, async ($, e) => {
135 if (e.args.trim() === 'print') {
136 await readCatalog($)
137 return { text: snapText(snapshot(await $.clock.now())) }
138 }
139 return { text: (await toggle($)) ? 'Almanac open.' : 'Almanac closed.' }
140 })
141
142 // Every slash command, whoever ran it.
143 on('command.run', async ($, e, next) => {
144 commands.add(e.command, await $.clock.now())
145 isDirty = true
146 return next(e)
147 }).catch(($, e, next) => next(e))
148
149 on('tool.call', async ($, e, next) => {
150 const at = await $.clock.now()
151 const tool = String(e.tool)
152 toolCount++
153 families.add(familyOf(tool), at)
154 if (tool === 'Skill') {
155 const skill = (e as unknown as Record<string, unknown>).skill
156 if (typeof skill === 'string') skills.add(skill, at)
157 }
158 const parts = mcpParts(tool, ownTools)
159 if (parts.server) mcp.add(parts.server, at)
160 if (parts.plugin) plugins.add(parts.plugin, at)
161 isDirty = true
162 return next(e)
163 }).catch(($, e, next) => next(e))
164
165 on('agent.spawn', async ($, e, next) => {
166 const at = await $.clock.now()
167 agents.add(e.subagentType, at)
168 const plugin = pluginOfAgent(e.subagentType)
169 if (plugin) plugins.add(plugin, at)
170 isDirty = true
171 return next(e)
172 }).catch(($, e, next) => next(e))
173
174 // The engine names the model of every step (a non-empty name).
175 on('turn.step', async function* ($, e, next) {
176 models.add(e.model, await $.clock.now())
177 isDirty = true
178 return yield* next(e)
179 })
180
181 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
182 const s = (await read($, snapAtom)) ?? snapshot(await $.clock.now())
183 const ui = $.ui.resolve(e)
184 const { Box, Text } = ui
185
186 if ('Svg' in ui && e.surface !== 'terminal') {
187 const { Svg } = ui
188 const W = pxOf(e.props.bodyColumns, 44)
189 const sections = [
190 { key: 'commands', alt: 'Slash commands run', ...barsSvg('Commands', KZ.yellow, s.commands, W, x => `/${x.name}`) },
191 { key: 'skills', alt: 'Skills invoked', ...cloudSvg('Skills', KZ.amber, s.skills, W) },
192 { key: 'agents', alt: 'Subagent types spawned', ...barsSvg('Subagents', KZ.violet, s.agents, W, x => x.name) },
193 { key: 'mcp', alt: 'MCP servers used', ...cloudSvg('MCP servers', KZ.magenta, s.mcp, W) },
194 { key: 'models', alt: 'Models seen', ...barsSvg('Models · requests', KZ.cyan, s.models, W, x => modelName(x.name)) },
195 { key: 'plugins', alt: 'Plugins whose tools were called', ...cloudSvg('Plugins', KZ.teal, s.plugins, W) },
196 { key: 'families', alt: 'Tool calls by family', ...familiesSvg(s.families, W) },
197 ]
198 return (
199 <Box flexDirection="column" gap={1}>
200 <Svg key="head" source={headerSvg(s, W)} alt={`Almanac: Claude Code ${s.version || 'unknown version'}, ${s.tools} tool calls`} width={W} height={112} />
201 {sections.map(x => <Svg key={x.key} source={x.source} alt={x.alt} width={W} height={x.height} />)}
202 </Box>
203 )
204 }
205
206 const cols = Math.max(30, e.props.bodyColumns || 40)
207 const nameW = Math.min(22, Math.max(10, Math.floor(cols * 0.38)))
208 const barW = Math.max(4, cols - nameW - 16)
209 const ranked = (key: string, title: string, color: string, xs: readonly AlmanacEntry[], label: (x: AlmanacEntry) => string) => {
210 const top = Math.max(1, ...xs.map(x => x.n))
211 return (
212 <Box key={key} flexDirection="column" marginTop={1}>
213 <Text bold color={color}>{title.toUpperCase()} <Text dimColor>{xs.length ? `${xs.length}` : 'none yet'}</Text></Text>
214 {xs.slice(0, 6).map((x, i) => (
215 <Box key={`${key}-${x.name}`} flexDirection="row">
216 <Box width={nameW} flexShrink={0}><Text wrap="truncate-end" bold={i === 0}>{label(x)}</Text></Box>
217 <Text color={i === 0 ? color : tagColor(x.name)}>{bar(x.n / top, Math.min(barW, 16))}</Text>
218 <Text> ×{x.n}</Text>
219 <Text dimColor> +{fmtClock(x.first)}</Text>
220 </Box>
221 ))}
222 {xs.length > 6 ? <Text dimColor> +{xs.length - 6} more</Text> : null}
223 </Box>
224 )
225 }
226 const cloud = (key: string, title: string, color: string, xs: readonly AlmanacEntry[]) => {
227 const top = Math.max(1, ...xs.map(x => x.n))
228 return (
229 <Box key={key} flexDirection="column" marginTop={1}>
230 <Text bold color={color}>{title.toUpperCase()} <Text dimColor>{xs.length ? `${xs.length}` : 'none yet'}</Text></Text>
231 <Box flexDirection="row" flexWrap="wrap">
232 {[...xs].slice(0, 24).sort((a, b) => a.name.localeCompare(b.name)).map(x => (
233 <Box key={`${key}-${x.name}`} marginRight={2}>
234 <Text color={tagColor(x.name)} bold={x.n >= top * 0.6} dimColor={x.n < top * 0.25}>
235 {x.name}{x.n > 1 ? <Text dimColor>·{x.n}</Text> : ''}
236 </Text>
237 </Box>
238 ))}
239 </Box>
240 </Box>
241 )
242 }
243 const famTotal = s.families.reduce((a, x) => a + x.n, 0)
244 const stackW = Math.max(10, cols - 2)
245 return (
246 <Box flexDirection="column">
247 <Box flexDirection="row" justifyContent="space-between">
248 <Text bold color={KZ.amber}>❦ ALMANAC</Text>
249 <Text dimColor>v{s.version || '?'} · {fmtClock(s.now - s.startedAt)}</Text>
250 </Box>
251 <Text wrap="truncate-end">
252 <Text color={KZ.green}>{s.tools}</Text><Text dimColor> tools · </Text>
253 <Text color={KZ.yellow}>{s.commands.reduce((a, x) => a + x.n, 0)}</Text><Text dimColor> commands · </Text>
254 <Text color={KZ.violet}>{s.agents.reduce((a, x) => a + x.n, 0)}</Text><Text dimColor> agents</Text>
255 </Text>
256 <Text dimColor wrap="truncate-end">
257 {s.installed >= 0 ? `${s.installed} commands installed, ${s.unused} never run here` : 'installed commands unknown'}
258 </Text>
259 {ranked('commands', 'Commands', KZ.yellow, s.commands, x => `/${x.name}`)}
260 {cloud('skills', 'Skills', KZ.amber, s.skills)}
261 {ranked('agents', 'Subagents', KZ.violet, s.agents, x => x.name)}
262 {cloud('mcp', 'MCP servers', KZ.magenta, s.mcp)}
263 {ranked('models', 'Models · requests', KZ.cyan, s.models, x => modelName(x.name))}
264 {cloud('plugins', 'Plugins', KZ.teal, s.plugins)}
265 <Box key="families" flexDirection="column" marginTop={1}>
266 <Text bold color={KZ.green}>TOOL FAMILIES <Text dimColor>{famTotal ? `${famTotal} calls` : 'none yet'}</Text></Text>
267 {famTotal > 0 && (
268 <Text wrap="truncate-end">
269 {s.families.map(x => (
270 <Text key={`fb-${x.name}`} color={FAMILY_COLOR[x.name as Family]}>{'█'.repeat(Math.max(1, Math.round((x.n / famTotal) * stackW)))}</Text>
271 ))}
272 </Text>
273 )}
274 <Box flexDirection="row" flexWrap="wrap">
275 {s.families.map(x => (
276 <Box key={`fl-${x.name}`} marginRight={2}>
277 <Text><Text color={FAMILY_COLOR[x.name as Family]}>■</Text> {familyLabel(x)} <Text dimColor>{x.n}</Text></Text>
278 </Box>
279 ))}
280 </Box>
281 </Box>
282 </Box>
283 )
284 })
285}
286hooks/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/book.ts 231 lines1// Almanac's pure helpers: the ledger it keeps, how a tool name tells its
2// family, MCP server and plugin, and the desktop drawings. No `$` here.
3
4import type { AlmanacEntry, AlmanacSnap } from '../types'
5import { FONT, KZ, fitText, fmtClock, hash, hue, modelName, svg, svgBar, svgText, textWidth, xml } from './lib/kz.ts'
6
7/** A counter that remembers when each name first showed up. */
8export class Tally {
9 private readonly m = new Map<string, { n: number; first: number }>()
10
11 add(name: string, at: number, by = 1): void {
12 if (!name) return
13 const prev = this.m.get(name)
14 if (prev) prev.n += by
15 else this.m.set(name, { n: by, first: at })
16 }
17
18 has(name: string): boolean {
19 return this.m.has(name)
20 }
21
22 /** Ranked: most first, then earliest. `first` relative to `origin`. */
23 entries(origin: number): AlmanacEntry[] {
24 return [...this.m.entries()]
25 .map(([name, v]) => ({ name, n: v.n, first: Math.max(0, v.first - origin) }))
26 .sort((a, b) => b.n - a.n || a.first - b.first || a.name.localeCompare(b.name))
27 }
28}
29
30export type Family = 'shell' | 'edit' | 'read' | 'search' | 'agent' | 'web' | 'tasks' | 'skill' | 'mcp' | 'other'
31
32export const FAMILY_COLOR: Record<Family, string> = {
33 shell: KZ.green,
34 edit: KZ.yellow,
35 read: KZ.blue,
36 search: KZ.teal,
37 agent: KZ.violet,
38 web: KZ.cyan,
39 tasks: KZ.lime,
40 skill: KZ.amber,
41 mcp: KZ.magenta,
42 other: KZ.mist,
43}
44
45export function familyOf(tool: string): Family {
46 const t = String(tool)
47 if (t.startsWith('mcp__')) return 'mcp'
48 if (t === 'Bash' || t === 'PowerShell' || t === 'Monitor') return 'shell'
49 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return 'edit'
50 if (t === 'Read' || t === 'LSP') return 'read'
51 if (t === 'Glob' || t === 'Grep' || t === 'ToolSearch') return 'search'
52 if (t === 'Agent' || t === 'Task' || t === 'Workflow' || t === 'SendMessage') return 'agent'
53 if (t.startsWith('Web')) return 'web'
54 if (t.startsWith('Todo') || t.startsWith('Task')) return 'tasks'
55 if (t === 'Skill') return 'skill'
56 return 'other'
57}
58
59/**
60 * What an `mcp__…` tool name says: the MCP server it belongs to, and the plugin
61 * that provides it. `mcp__plugin_<plugin>_<server>__<tool>` is a plugin's MCP
62 * server; `mcp__<x>__<tool>` is a plugin's own registered tool when the engine
63 * lists it as not MCP (`ownTools`), else the MCP server `<x>`.
64 */
65export function mcpParts(tool: string, ownTools: ReadonlySet<string>): { server?: string; plugin?: string } {
66 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
67 if (!m) return {}
68 const head = m[1]! // the pattern always captures it
69 const pm = /^plugin_([^_]+)_(.+)$/.exec(head)
70 if (pm) return { server: pm[2], plugin: pm[1] }
71 if (ownTools.has(tool)) return { plugin: head }
72 return { server: head }
73}
74
75/** A plugin's agent type reads `plugin:name`; built-ins have no prefix. */
76export function pluginOfAgent(type: string): string | undefined {
77 const i = type.indexOf(':')
78 return i > 0 ? type.slice(0, i) : undefined
79}
80
81export function snapText(s: AlmanacSnap): string {
82 const sec = (title: string, xs: AlmanacEntry[], fmt: (e: AlmanacEntry) => string = e => e.name) =>
83 `${title}: ${xs.length ? xs.map(e => `${fmt(e)} ×${e.n}`).join(', ') : '—'}`
84 return [
85 `Almanac — Claude Code ${s.version || '?'} · ${fmtClock(s.now - s.startedAt)} · ${s.tools} tool calls`,
86 sec('Commands', s.commands, e => `/${e.name}`),
87 sec('Skills', s.skills),
88 sec('Subagents', s.agents),
89 sec('MCP servers', s.mcp),
90 sec('Models', s.models, e => modelName(e.name)),
91 sec('Plugins', s.plugins),
92 sec('Tool families', s.families),
93 s.installed >= 0 ? `Installed commands: ${s.installed}, never run this session: ${s.unused}` : 'Installed commands: unknown',
94 ].join('\n')
95}
96
97export const tagColor = (name: string): string => hue((hash(name) % 997) / 997, 0.78, 0.57)
98
99// ---------------------------------------------------------------------------
100// Desktop drawings: one SVG per section.
101
102export function headerSvg(s: AlmanacSnap, W: number): string {
103 const H = 112
104 const parts = [
105 `<defs><linearGradient id="ah" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${KZ.amber}" stop-opacity=".22"/><stop offset=".6" stop-color="${KZ.magenta}" stop-opacity=".1"/><stop offset="1" stop-color="${KZ.violet}" stop-opacity=".05"/></linearGradient></defs>`,
106 `<rect class="p" width="${W}" height="${H}" rx="14"/><rect width="${W}" height="${H}" rx="14" fill="url(#ah)"/>`,
107 // A small open book.
108 `<g transform="translate(16 14)"><path d="M0 4q9-5 16 0v20q-7-5-16 0z" fill="${KZ.amber}" opacity=".9"/><path d="M32 4q-9-5-16 0v20q7-5 16 0z" fill="${KZ.amber}" opacity=".65"/></g>`,
109 svgText(56, 30, 'ALMANAC', { size: 16, weight: 800, fill: KZ.amber }),
110 svgText(56, 45, `what this session is made of · ${fmtClock(s.now - s.startedAt)}`, { cls: 's', size: 10.5 }),
111 ]
112 const ver = s.version ? `v${s.version}` : 'v?'
113 const vw = textWidth(ver, 10.5) + 16
114 parts.push(`<rect x="${W - 12 - vw}" y="14" width="${vw}" height="20" rx="10" fill="${KZ.violet}" opacity=".18"/>`)
115 parts.push(svgText(W - 12 - vw / 2, 28, ver, { size: 10.5, weight: 700, anchor: 'middle', fill: KZ.violet, mono: true }))
116 const tiles: [string, string, string][] = [
117 ['TOOLS', String(s.tools), KZ.green],
118 ['COMMANDS', String(s.commands.reduce((a, e) => a + e.n, 0)), KZ.yellow],
119 ['SKILLS', String(s.skills.length), KZ.amber],
120 ['AGENTS', String(s.agents.reduce((a, e) => a + e.n, 0)), KZ.violet],
121 ['MCP', String(s.mcp.length), KZ.magenta],
122 ['UNUSED', s.installed >= 0 ? `${s.unused}/${s.installed}` : '—', KZ.mist],
123 ]
124 const n = W >= 520 ? 6 : 3
125 const rowsOf = Math.ceil(tiles.length / n)
126 const tw = (W - 24 - (n - 1) * 6) / n
127 const th = rowsOf === 1 ? 46 : 24
128 tiles.forEach(([k, v, c], i) => {
129 const x = 12 + (i % n) * (tw + 6)
130 const y = 56 + Math.floor(i / n) * (th + 4)
131 parts.push(`<rect class="k" x="${x}" y="${y}" width="${tw}" height="${th}" rx="8" opacity=".7"/>`)
132 if (rowsOf === 1) {
133 parts.push(svgText(x + 10, y + 16, k, { cls: 'm', size: 9, weight: 700 }))
134 parts.push(svgText(x + 10, y + 36, fitText(v, 16, tw - 14), { size: 16, weight: 760, fill: c }))
135 } else {
136 parts.push(svgText(x + 8, y + 16, k, { cls: 'm', size: 8.5, weight: 700 }))
137 parts.push(svgText(x + tw - 8, y + 16.5, fitText(v, 12, tw / 2), { size: 12, weight: 760, anchor: 'end', fill: c }))
138 }
139 })
140 return svg(W, H, parts.join(''))
141}
142
143function sectionHead(title: string, sub: string, color: string, W: number): string {
144 return `<rect class="p" x="0" y="0" width="${W}" height="100%" rx="12"/>` +
145 svgText(12, 19, title.toUpperCase(), { size: 10, weight: 760, fill: color }) +
146 svgText(W - 12, 19, sub, { cls: 'm', size: 10, anchor: 'end' })
147}
148
149/** Ranked bars: name, bar, count, first use. */
150export function barsSvg(title: string, color: string, xs: readonly AlmanacEntry[], W: number, label: (e: AlmanacEntry) => string, max = 8): { source: string; height: number } {
151 const shown = xs.slice(0, max)
152 const H = 30 + Math.max(1, shown.length) * 20 + 6
153 const top = Math.max(1, ...shown.map(e => e.n))
154 const total = xs.reduce((a, e) => a + e.n, 0)
155 const parts = [sectionHead(title, xs.length ? `${xs.length} · ${total}×` : '', color, W)]
156 if (!shown.length) parts.push(svgText(12, 44, 'none yet', { cls: 'm', size: 11 }))
157 const nameW = Math.min(170, Math.max(70, W * 0.32))
158 shown.forEach((e, i) => {
159 const y = 30 + i * 20
160 const c = i === 0 ? color : tagColor(e.name)
161 parts.push(svgText(12, y + 12, fitText(label(e), 11.5, nameW - 8), { size: 11.5, weight: i === 0 ? 650 : 500 }))
162 parts.push(svgBar(12 + nameW, y + 4, Math.max(30, W - nameW - 110), 8, e.n / top, c))
163 parts.push(svgText(W - 54, y + 12, `×${e.n}`, { size: 11, weight: 700, anchor: 'end', fill: c }))
164 parts.push(svgText(W - 12, y + 12, `+${fmtClock(e.first)}`, { cls: 'm', size: 9.5, anchor: 'end', mono: true }))
165 })
166 if (xs.length > max) parts.push(svgText(W - 12, H - 6, `+${xs.length - max} more`, { cls: 'm', size: 9, anchor: 'end' }))
167 return { source: svg(W, H, parts.join('')), height: H }
168}
169
170/** A tag cloud: pills sized by use, flowing onto as many lines as they need. */
171export function cloudSvg(title: string, color: string, xs: readonly AlmanacEntry[], W: number, label: (e: AlmanacEntry) => string = e => e.name, max = 30): { source: string; height: number } {
172 const shown = xs.slice(0, max)
173 const top = Math.max(1, ...shown.map(e => e.n))
174 const pills: string[] = []
175 let x = 12
176 let y = 30
177 let lineH = 0
178 // Alphabetical inside the cloud reads like a cloud; size carries the rank.
179 const sorted = [...shown].sort((a, b) => a.name.localeCompare(b.name))
180 for (const e of sorted) {
181 const size = 10.5 + 8 * Math.sqrt(e.n / top)
182 const text = label(e)
183 const count = e.n > 1 ? ` ${e.n}` : ''
184 const w = Math.min(W - 24, textWidth(text, size) + textWidth(count, 9) + 18)
185 const h = size + 10
186 if (x + w > W - 12 && x > 12) {
187 x = 12
188 y += lineH + 6
189 lineH = 0
190 }
191 const c = tagColor(e.name)
192 pills.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="${c}" opacity="${0.12 + 0.18 * (e.n / top)}"/>`)
193 pills.push(`<text x="${x + 9}" y="${y + h / 2 + size * 0.35}" font-family="${FONT}" font-size="${size.toFixed(1)}" font-weight="${e.n === top ? 750 : 560}" fill="${c}">${xml(fitText(text, size, w - 18))}<tspan font-size="9" opacity=".75">${count}</tspan></text>`)
194 x += w + 6
195 lineH = Math.max(lineH, h)
196 }
197 const H = shown.length ? y + lineH + 10 : 54
198 const head = sectionHead(title, xs.length ? `${xs.length}` : '', color, W)
199 const empty = shown.length ? '' : svgText(12, 42, 'none yet', { cls: 'm', size: 11 })
200 return { source: svg(W, H, head + empty + pills.join('')), height: H }
201}
202
203/** Tool families: one stacked bar and a legend. */
204export function familiesSvg(xs: readonly AlmanacEntry[], W: number): { source: string; height: number } {
205 const total = xs.reduce((a, e) => a + e.n, 0)
206 const cols = W >= 480 ? 4 : 2
207 const legendRows = Math.max(1, Math.ceil(xs.length / cols))
208 const H = 52 + legendRows * 17 + 4
209 const parts = [sectionHead('Tool families', total ? `${total} calls` : '', KZ.green, W)]
210 const bw = W - 24
211 parts.push(`<rect class="k" x="12" y="28" width="${bw}" height="12" rx="6"/>`)
212 if (total) {
213 parts.push(`<clipPath id="fam"><rect x="12" y="28" width="${bw}" height="12" rx="6"/></clipPath><g clip-path="url(#fam)">`)
214 let x = 12
215 for (const e of xs) {
216 const w = (e.n / total) * bw
217 parts.push(`<rect x="${x.toFixed(1)}" y="28" width="${w.toFixed(1)}" height="12" fill="${FAMILY_COLOR[e.name as Family]}"/>`)
218 x += w
219 }
220 parts.push('</g>')
221 } else parts.push(svgText(12, 62, 'no tool calls yet', { cls: 'm', size: 11 }))
222 const slot = bw / cols
223 xs.forEach((e, i) => {
224 const lx = 12 + (i % cols) * slot
225 const ly = 58 + Math.floor(i / cols) * 17
226 parts.push(`<rect x="${lx}" y="${ly - 8}" width="9" height="9" rx="2.5" fill="${FAMILY_COLOR[e.name as Family]}"/>`)
227 parts.push(svgText(lx + 14, ly, fitText(`${e.name} ${e.n} · ${Math.round((e.n / total) * 100)}%`, 10.5, slot - 18), { cls: 's', size: 10.5 }))
228 })
229 return { source: svg(W, H, parts.join('')), height: H }
230}
231types/index.d.ts 29 lines1/** One thing the session used: how often, and when first (ms since the session began). */
2export type AlmanacEntry = { name: string; n: number; first: number }
3
4export type AlmanacSnap = {
5 /** Engine version, '' until $.session.version() answered. */
6 version: string
7 /** Session start ($.clock.now() ms) and the latest sample time. */
8 startedAt: number
9 now: number
10 commands: AlmanacEntry[]
11 skills: AlmanacEntry[]
12 agents: AlmanacEntry[]
13 mcp: AlmanacEntry[]
14 models: AlmanacEntry[]
15 plugins: AlmanacEntry[]
16 families: AlmanacEntry[]
17 /** Every tool call seen. */
18 tools: number
19 /** Slash commands the session offers ($.command.list), and how many of them never ran; -1 until listed. */
20 installed: number
21 unused: number
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 almanac: { snap: AlmanacSnap | null }
27 }
28}
29