SLOPSHOPPER

almanac

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

newpaneguardcommandtimeragents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/almanac
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · almanac
│ ┃ KOZMOS · Almanac ✕ › fix the failing auth test and add an audit log call │ ┃ ❦ ALMANAC v2.1.289 · 0:00 │ ┃ 9 tools · 0 commands · 0 agents ⏺ Read(src/auth.ts) │ ┃ 1 commands installed, 1 never run here ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ COMMANDS none yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ SKILLS none yet ⎿ 3 pass, 1 fail │ ┃ │ ┃ SUBAGENTS none yet ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ MCP SERVERS none yet ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ MODELS · REQUESTS none yet › /almanac │ ┃ ⎿ almanac: Almanac open. │ ┃ PLUGINS none yet │ ┃ │ ┃ TOOL FAMILIES 9 calls │ ┃ ███████████████████████████████████████████… │ ┃ ■ shell 4 ■ edit 3 ■ read 1 ■ search 1 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Almanac
❦ ALMANAC v2.1.289 · 0:00 9 tools · 0 commands · 0 agents 1 commands installed, 1 never run here COMMANDS none yet SKILLS none yet SUBAGENTS none yet MCP SERVERS none yet MODELS · REQUESTS none yet PLUGINS none yet TOOL FAMILIES 9 calls ██████████████████████████████████████████████████████ ■ shell 4 ■ edit 3 ■ read 1 ■ search 1
README

KOZMOS Almanac

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):

  • Commands: every slash command run, ranked.
  • Skills: every skill the model invoked with the Skill tool, as a tag cloud.
  • Subagents: the agent types spawned, ranked.
  • MCP servers: the servers whose mcp__<server>__… tools were called.
  • Models: each model seen on a request, with its request count.
  • Plugins: plugins whose tools (or agent types) were used.
  • Tool families: shell, edit, read, search, agent, web, tasks, skill, mcp, as one stacked bar with a legend.

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.

Türkçe

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.

Source 4 files
hooks/register.tsx 286 lines
1import { 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}
286
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/book.ts 231 lines
1// 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}
231
types/index.d.ts 29 lines
1/** 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