SLOPSHOPPER

epilogue

KOZMOS Epilogue: a recap band after every turn. Duration, tool families as colored chips, files written, subagents spawned, tokens and estimated cost, the…

newpanebandguardcommandprompt
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/epilogue
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · epilogue
│ ┃ KOZMOS · Epilogue ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Epilogue │ ┃ No turns yet: a recap lands here after each ⏺ Read(src/auth.ts) │ ┃ one. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /epilogue │ ⎿ epilogue: Epilogue open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Epilogue
◆ Epilogue No turns yet: a recap lands here after each one.
README

Epilogue

A recap after every main turn, shown as a compact band until you send the next prompt:

  • how long the turn took, its estimated cost (summed from every model request of the turn, subagents included, at list prices), the session cost delta from the engine's own ledger, and the tokens moved;
  • the tools used, as colored chips per family (Edit, Shell, Read, Agent, Web, Tasks, MCP);
  • the files written (Edit / Write / NotebookEdit), the subagents spawned and the tools that failed;
  • optionally an AI one-liner: with the aiSummary setting on, claude-haiku-5-5 sums up the answer in at most 15 words.

/epilogue opens a pane with the last 20 recaps; /epilogue hide / show (or ✕) controls the band. Terminal: colored text with filled chips. Desktop: one SVG card per recap, sliding in with a shine.

Türkçe

Her ana turdan sonra, bir sonraki isteme kadar görünen kısa bir özet bandı: süre, tahmini maliyet ve oturum maliyet farkı, token sayısı, araç ailelerine göre renkli etiketler, yazılan dosyalar, başlatılan alt ajanlar ve başarısız araçlar. aiSummary açıksa claude-haiku-5-5 cevabı en fazla 15 kelimeyle özetler. /epilogue son 20 özeti bir panelde açar.

Source 4 files
hooks/register.tsx 347 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementConstructor, EngineInterface, Register, RenderChildren, TextProps } from 'claude-code'
3
4import type { EpilogueRecap } from '../types'
5import { KZ, fitText, fmtClock, fmtTokens, fmtUsd, pxOf, svg, svgText, textWidth, toolDetail, xml } from './lib/kz.ts'
6import { FAMILIES, addTool, addUsage, cleanSummary, fileList, newTally, summaryPrompt, toRecap } from './recap.ts'
7import type { Family, Tally } from './recap.ts'
8
9const PANE = 'kz-epilogue'
10const TITLE = 'KOZMOS · Epilogue'
11const KEEP = 20
12const SUMMARY_MODEL = 'claude-haiku-5-5'
13
14const recapsAtom = atom({ plugin: 'epilogue', key: 'recaps' } as const, [])
15const shownAtom = atom({ plugin: 'epilogue', key: 'isShown' } as const, false)
16const hiddenAtom = atom({ plugin: 'epilogue', key: 'isHidden' } as const, false)
17
18let tally: Tally | null = null
19let wantsSummary = false
20
21async function costNow($: EngineInterface): Promise<number | undefined> {
22  try {
23    return (await $.session.usage()).cost?.usd
24  } catch {
25    return undefined
26  }
27}
28
29async function setHidden($: EngineInterface, value: boolean): Promise<void> {
30  await update($, hiddenAtom, () => value)
31  try {
32    await $.store.set('hidden', value)
33  } catch {
34    // Session only.
35  }
36}
37
38async function togglePane($: EngineInterface): Promise<boolean> {
39  if ((await $.ui.panes()).some(p => p.id === PANE)) {
40    await $.ui.close({ id: PANE })
41    return false
42  }
43  await $.ui.open({ id: PANE, title: TITLE })
44  return true
45}
46
47async function summarize($: EngineInterface, id: string, answer: string): Promise<void> {
48  let summary: string | undefined
49  try {
50    const r = await $.model.complete({ model: SUMMARY_MODEL, prompt: summaryPrompt(answer), maxTokens: 80, effort: 'low', timeoutMs: 20_000 })
51    summary = r.isAnswered ? cleanSummary(r.text) : undefined
52  } catch {
53    summary = undefined
54  }
55  if (!summary) return
56  const text = summary
57  await update($, recapsAtom, list => list.map(r => (r.id === id ? { ...r, summary: text } : r)))
58}
59
60export const register: Register = (on, options) => {
61  on('session.start', async ($, e, next) => {
62    const started = await next(e)
63    tally = null
64    wantsSummary = options.aiSummary === true
65    try {
66      const isHidden = (await $.store.get('hidden')) === true
67      await update($, hiddenAtom, () => isHidden)
68    } catch {
69      // Shown by default.
70    }
71    await $.command.register({ name: 'epilogue', description: 'KOZMOS: open the last 20 turn recaps (hide / show the band)', argumentHint: '[hide | show]', immediate: true })
72    return started
73  })
74
75  on('command.run', { command: 'epilogue' }, async ($, e) => {
76    const verb = e.args.trim().toLowerCase()
77    if (verb === 'hide' || verb === 'show') {
78      await setHidden($, verb === 'hide')
79      return { text: verb === 'hide' ? 'Epilogue band hidden; /epilogue still lists recaps.' : 'Epilogue band shown after each turn.' }
80    }
81    return { text: (await togglePane($)) ? 'Epilogue open.' : 'Epilogue closed.' }
82  })
83
84  on('prompt.submit', async ($, e, next) => {
85    await update($, shownAtom, () => false)
86    return next(e)
87  }).catch(($, e, next) => next(e))
88
89  on('turn.start', async ($, e, next) => {
90    tally = newTally(e.turnId, await $.clock.now(), e.text, await costNow($))
91    await update($, shownAtom, () => false)
92    return next(e)
93  }).catch(($, e, next) => next(e))
94
95  on('turn.step', async function* ($, e, next) {
96    const r = yield* next(e)
97    if (tally && r.usage) addUsage(tally, r.usage.model || e.model, r.usage)
98    return r
99  })
100
101  on('tool.call', async ($, e, next) => {
102    const ran = await next(e)
103    if (tally) {
104      const failed = ran.isError === true || ran.deny !== undefined
105      addTool(tally, String(e.tool), e as unknown as Record<string, unknown>, failed, toolDetail(String(e.tool), e))
106    }
107    return ran
108  }).catch(($, e, next) => next(e))
109
110  on('agent.spawn', async ($, e, next) => {
111    const r = await next(e)
112    if (tally && (r as { agentId?: string }).agentId) tally.agents++
113    return r
114  }).catch(($, e, next) => next(e))
115
116  on('turn.complete', async ($, e, next) => {
117    const done = await next(e)
118    if (e.agentId !== undefined || !tally) return done
119    const t = tally
120    tally = null
121    const recap = toRecap(t, await $.clock.now(), e.durationMs, e.reason, await costNow($))
122    await update($, recapsAtom, list => [...list, recap].slice(-KEEP))
123    await update($, shownAtom, () => true)
124    if (wantsSummary && e.answer.trim()) void summarize($, recap.id, e.answer).catch(() => undefined)
125    return done
126  }).catch(($, e, next) => next(e))
127
128  // ---- the band -------------------------------------------------------------
129  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130    const drawn = await next(e)
131    if (e.props.hasSurvey || e.props.isWorking) return drawn
132    if (!(await read($, shownAtom)) || (await read($, hiddenAtom))) return drawn
133    // Shown only once turn.complete stored a recap.
134    const recaps = await read($, recapsAtom)
135    const r = recaps[recaps.length - 1]!
136    const { Box, Button, Text } = $.ui.resolve(e)
137    const cols = Math.max(40, e.props.bodyColumns || 80)
138    const hide = <Button key="epilogue-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
139
140    // The terminal draws text rows; every other surface one SVG card.
141    if (e.surface !== 'terminal') {
142      const { Svg } = $.ui.resolve(e)
143      const W = pxOf(cols)
144      const card = recapCard(r, W, { isBand: true })
145      return (
146        <Box flexDirection="column">
147          {drawn}
148          <Box key="epilogue" flexDirection="row">
149            <Svg source={card.source} alt={altOf(r)} width={W} height={card.height} />
150            {hide}
151          </Box>
152        </Box>
153      )
154    }
155
156    const lines = termLines(r, cols - 3, Text)
157    return (
158      <Box flexDirection="column">
159        {drawn}
160        <Box key="epilogue" flexDirection="row">
161          <Box flexDirection="column" flexGrow={1}>
162            {lines}
163          </Box>
164          {hide}
165        </Box>
166      </Box>
167    )
168  })
169
170  // ---- the pane -------------------------------------------------------------
171  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
172    const recaps = [...(await read($, recapsAtom))].reverse()
173    const { Box, Text } = $.ui.resolve(e)
174    const cols = Math.max(32, e.props.bodyColumns || 60)
175    if (recaps.length === 0) {
176      return (
177        <Box flexDirection="column">
178          <Text color={KZ.violet} bold>◆ Epilogue</Text>
179          <Text dimColor>No turns yet: a recap lands here after each one.</Text>
180        </Box>
181      )
182    }
183    if (e.surface !== 'terminal') {
184      const { Svg } = $.ui.resolve(e)
185      const W = pxOf(cols, 60)
186      return (
187        <Box flexDirection="column">
188          {recaps.map((r, i) => {
189            const card = recapCard(r, W, { isBand: false, index: recaps.length - i })
190            return <Svg key={`ep-${r.id}`} source={card.source} alt={altOf(r)} width={W} height={card.height} />
191          })}
192        </Box>
193      )
194    }
195    return (
196      <Box flexDirection="column">
197        {recaps.map((r, i) => (
198          <Box key={`ep-${r.id}`} flexDirection="column" marginBottom={1}>
199            <Text wrap="truncate-end">
200              <Text color={KZ.violet} bold>#{recaps.length - i} </Text>
201              <Text dimColor>{timeOf(r.endedAt)} · </Text>
202              <Text>{r.prompt ? `“${r.prompt}”` : '(continuation)'}</Text>
203            </Text>
204            {termLines(r, cols, Text, true)}
205          </Box>
206        ))}
207      </Box>
208    )
209  })
210}
211
212// ---------------------------------------------------------------------------
213
214type TextTag = ElementConstructor<TextProps>
215
216function timeOf(ms: number): string {
217  const d = new Date(ms)
218  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
219}
220
221function costLine(r: EpilogueRecap): string {
222  const est = r.estUsd > 0 ? `~${fmtUsd(r.estUsd)}` : '$0'
223  return r.sessionDeltaUsd !== undefined ? `${est} (session +${fmtUsd(r.sessionDeltaUsd)})` : est
224}
225
226function reasonMark(r: EpilogueRecap): string {
227  return r.reason === 'aborted' ? '⏹ interrupted' : r.reason === 'error' ? '⚠ ended on an error' : r.reason === 'refusal' ? '⊘ refused' : ''
228}
229
230function altOf(r: EpilogueRecap): string {
231  const tools = r.tools.map(t => `${FAMILIES[t.family as Family].label} ${t.count}`).join(', ')
232  return [
233    `Turn recap: ${fmtClock(r.durationMs)}`,
234    `${fmtTokens(r.tokens)} tokens, ${costLine(r)}`,
235    tools ? `tools ${tools}` : 'no tools',
236    r.files.length ? `files ${r.files.join(', ')}` : '',
237    r.agents ? `${r.agents} subagents` : '',
238    r.failed.length ? `${r.failed.length} failed` : '',
239    r.summary ?? '',
240  ].filter(Boolean).join('; ')
241}
242
243/** One or two rows of colored text: the figures and chips, then files, agents and the one-liner. */
244function termLines(r: EpilogueRecap, cols: number, Text: TextTag, isPane = false): RenderChildren[] {
245  const chips = r.tools.map(t => {
246    const f = FAMILIES[t.family as Family]
247    return [
248      <Text key={`c-${t.family}`} backgroundColor={f.color} color="#111111" bold> {f.glyph} {t.count} </Text>,
249      <Text key={`s-${t.family}`}> </Text>,
250    ]
251  })
252  const mark = reasonMark(r)
253  const row1 = (
254    <Text key="r1" wrap="truncate-end">
255      {isPane ? null : <Text color={KZ.violet} bold>◆ </Text>}
256      <Text bold>{fmtClock(r.durationMs)}</Text>
257      <Text dimColor> · </Text>
258      <Text color={KZ.yellow}>{costLine(r)}</Text>
259      <Text dimColor> · {fmtTokens(r.tokens)} tok · </Text>
260      {chips.length ? chips : <Text dimColor>no tools </Text>}
261      {r.failed.length ? <Text color={KZ.red} bold>✖ {r.failed.length} failed </Text> : null}
262      {mark ? <Text color={KZ.amber}>{mark}</Text> : null}
263    </Text>
264  )
265  const bits: RenderChildren[] = []
266  if (r.files.length) bits.push(<Text key="f" color={KZ.yellow}>✎ <Text dimColor>{fileList(r.files, Math.max(16, Math.floor(cols * 0.45)))}</Text></Text>)
267  if (r.agents) bits.push(<Text key="a" color={KZ.violet}>◈ {r.agents} agent{r.agents === 1 ? '' : 's'}</Text>)
268  if (isPane && r.failed.length) bits.push(<Text key="x" color={KZ.red}>✖ {r.failed.map(f => `${f.tool}${f.detail ? ` ${f.detail}` : ''}`).join(', ')}</Text>)
269  if (r.summary) bits.push(<Text key="s" italic color={KZ.cyan}>“{r.summary}”</Text>)
270  if (!bits.length) return [row1]
271  const joined: RenderChildren[] = []
272  bits.forEach((b, i) => {
273    if (i) joined.push(<Text key={`d${i}`} dimColor> · </Text>)
274    joined.push(b)
275  })
276  return [row1, <Text key="r2" wrap="truncate-end">{isPane ? '' : '  '}{joined}</Text>]
277}
278
279/** The recap as one SVG card: figures, family chips, files, failures and the one-liner. */
280function recapCard(r: EpilogueRecap, W: number, opts: { isBand: boolean; index?: number }): { source: string; height: number } {
281  const pad = 14
282  const hasLine3 = Boolean(r.summary) || (!opts.isBand && Boolean(r.prompt))
283  const H = hasLine3 ? 74 : 54
284  const css = `
285.ep-ep{animation:epIn .45s cubic-bezier(.2,.8,.2,1) both}@keyframes epIn{from{opacity:0;transform:translateY(5px)}}
286.ep-chip{animation:epChip .4s cubic-bezier(.2,.8,.2,1) both}@keyframes epChip{from{opacity:0;transform:scale(.6)}}
287.ep-chip{transform-box:fill-box;transform-origin:center}
288.ep-shine{animation:epShine${W} 1.6s ease-out .35s both}@keyframes epShine${W}{from{transform:translateX(-160px)}to{transform:translateX(${W + 160}px)}}
289.ep-accent{animation:epHue 6s linear infinite}@keyframes epHue{50%{opacity:.55}}
290`
291  const p: string[] = []
292  p.push(`<defs><linearGradient id="epA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.violet}"/><stop offset="1" stop-color="${KZ.cyan}"/></linearGradient>` +
293    `<linearGradient id="epS" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".22"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
294    `<clipPath id="epC${W}x${H}"><rect x="0" y="0" width="${W}" height="${H}" rx="12"/></clipPath></defs>`)
295  p.push(`<g class="ep-ep">`)
296  p.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`)
297  p.push(`<rect class="ep-accent" x="0" y="0" width="4" height="${H}" fill="url(#epA)" clip-path="url(#epC${W}x${H})"/>`)
298  // Line 1: label, duration, cost, tokens; failures and the end reason on the right.
299  let x = pad
300  const label = opts.index !== undefined ? `#${opts.index} · ${timeOf(r.endedAt)}` : 'EPILOGUE'
301  p.push(svgText(x, 21, label, { size: 10, weight: 700, fill: KZ.violet }))
302  x += textWidth(label, 10) + 12
303  p.push(svgText(x, 21, fmtClock(r.durationMs), { size: 13, weight: 700, mono: true }))
304  x += textWidth(fmtClock(r.durationMs), 13) + 14
305  p.push(svgText(x, 21, costLine(r), { size: 12, weight: 600, fill: '#eab308' }))
306  x += textWidth(costLine(r), 12) + 14
307  p.push(svgText(x, 21, `${fmtTokens(r.tokens)} tokens`, { cls: 's', size: 11.5 }))
308  let rx = W - pad - (opts.isBand ? 18 : 0)
309  if (r.failed.length) {
310    const t = `✖ ${r.failed.length} failed`
311    const w = textWidth(t, 10.5) + 16
312    rx -= w
313    p.push(`<rect x="${rx}" y="9" width="${w}" height="17" rx="8.5" fill="${KZ.red}" opacity=".16"/>`)
314    p.push(svgText(rx + w / 2, 21, t, { size: 10.5, weight: 700, anchor: 'middle', fill: KZ.red }))
315    rx -= 8
316  }
317  const mark = reasonMark(r)
318  if (mark) p.push(svgText(rx, 21, mark, { size: 10.5, weight: 600, anchor: 'end', fill: KZ.amber }))
319  // Line 2: chips, files, agents.
320  x = pad
321  r.tools.forEach((t, i) => {
322    const f = FAMILIES[t.family as Family]
323    const txt = `${f.glyph} ${f.label} ${t.count}`
324    const w = textWidth(txt, 10.5) + 16
325    if (x + w > W - pad) return
326    p.push(`<g class="ep-chip" style="animation-delay:${(0.08 + i * 0.06).toFixed(2)}s"><rect x="${x}" y="31" width="${w}" height="18" rx="9" fill="${f.color}" opacity=".2"/>` +
327      svgText(x + w / 2, 44, txt, { size: 10.5, weight: 650, anchor: 'middle', fill: f.color }) + `</g>`)
328    x += w + 6
329  })
330  if (!r.tools.length) {
331    p.push(svgText(x, 44, 'no tools', { cls: 'm', size: 10.5 }))
332    x += 56
333  }
334  const tail: string[] = []
335  if (r.agents) tail.push(`◈ ${r.agents} agent${r.agents === 1 ? '' : 's'}`)
336  if (r.files.length) tail.push(`✎ ${fileList(r.files, Math.max(12, Math.floor((W - x - pad) / 6.6) - 14))}`)
337  if (tail.length && x < W - pad - 60) p.push(svgText(x + 6, 44, fitText(tail.join('   '), 10.5, W - pad - x - 6), { cls: 's', size: 10.5, mono: true }))
338  // Line 3: the one-liner, or (in the pane) the prompt.
339  if (hasLine3) {
340    const body = r.summary ? `“${r.summary}”` : `› ${r.prompt}`
341    p.push(`<text ${r.summary ? `fill="${KZ.cyan}"` : 'class="m"'} x="${pad}" y="64" font-size="11.5" font-style="${r.summary ? 'italic' : 'normal'}" font-family="-apple-system,'Segoe UI',sans-serif">${xml(fitText(body, 11.5, W - pad * 2))}</text>`)
342  }
343  p.push(`<g clip-path="url(#epC${W}x${H})"><rect class="ep-shine" x="0" y="0" width="140" height="${H}" fill="url(#epS)"/></g>`)
344  p.push(`</g>`)
345  return { source: svg(W, H, p.join(''), css), height: H }
346}
347
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/recap.ts 139 lines
1// Epilogue's bookkeeping, pure: tool families, the running tally of a turn,
2// and the recap it becomes.
3import type { EpilogueRecap } from '../types'
4import { KZ, baseName, costOf, tokensOf } from './lib/kz.ts'
5import type { Usage } from './lib/kz.ts'
6
7export type Family = 'edit' | 'shell' | 'read' | 'agent' | 'web' | 'tasks' | 'mcp' | 'other'
8
9export const FAMILIES: Record<Family, { label: string; glyph: string; color: string }> = {
10  edit: { label: 'Edit', glyph: '✎', color: KZ.yellow },
11  shell: { label: 'Shell', glyph: '$', color: KZ.green },
12  read: { label: 'Read', glyph: '◉', color: KZ.blue },
13  agent: { label: 'Agent', glyph: '◈', color: KZ.violet },
14  web: { label: 'Web', glyph: '◍', color: KZ.cyan },
15  tasks: { label: 'Tasks', glyph: '☑', color: KZ.teal },
16  mcp: { label: 'MCP', glyph: '⬡', color: KZ.magenta },
17  other: { label: 'Other', glyph: '•', color: KZ.mist },
18}
19
20export const FAMILY_ORDER: readonly Family[] = ['edit', 'shell', 'read', 'agent', 'web', 'tasks', 'mcp', 'other']
21
22export function familyOf(tool: string): Family {
23  const t = String(tool)
24  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return 'edit'
25  if (t === 'Bash' || t === 'PowerShell' || t === 'Monitor') return 'shell'
26  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP' || t === 'ToolSearch') return 'read'
27  if (t === 'Agent' || t === 'Task' || t === 'Workflow' || t === 'SendMessage') return 'agent'
28  if (t.startsWith('Web')) return 'web'
29  if (t.startsWith('Todo') || t.startsWith('Task')) return 'tasks'
30  if (t.startsWith('mcp__')) return 'mcp'
31  return 'other'
32}
33
34/** The file a tool call writes, when it writes one. */
35export function writtenFile(tool: string, input: Record<string, unknown>): string | undefined {
36  if (tool !== 'Edit' && tool !== 'Write' && tool !== 'NotebookEdit' && tool !== 'MultiEdit') return undefined
37  const p = input.file_path ?? input.notebook_path
38  return typeof p === 'string' && p ? p : undefined
39}
40
41export type Tally = {
42  turnId: string
43  startedAt: number
44  prompt: string
45  costAtStart?: number
46  tools: Partial<Record<Family, number>>
47  files: string[]
48  agents: number
49  failed: { tool: string; detail: string }[]
50  usage: Required<Usage>
51  estUsd: number
52  steps: number
53}
54
55export function newTally(turnId: string, startedAt: number, prompt: string, costAtStart?: number): Tally {
56  return {
57    turnId,
58    startedAt,
59    prompt,
60    costAtStart,
61    tools: {},
62    files: [],
63    agents: 0,
64    failed: [],
65    usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
66    estUsd: 0,
67    steps: 0,
68  }
69}
70
71export function addTool(t: Tally, tool: string, input: Record<string, unknown>, failed: boolean, detail: string): void {
72  const fam = familyOf(tool)
73  t.tools[fam] = (t.tools[fam] ?? 0) + 1
74  const f = writtenFile(tool, input)
75  if (f && !t.files.includes(f)) t.files.push(f)
76  if (failed) t.failed.push({ tool, detail })
77}
78
79export function addUsage(t: Tally, model: string, u: Usage | null | undefined): void {
80  if (!u) return
81  t.usage.input_tokens += u.input_tokens ?? 0
82  t.usage.output_tokens += u.output_tokens ?? 0
83  t.usage.cache_read_input_tokens += u.cache_read_input_tokens ?? 0
84  t.usage.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0
85  t.estUsd += costOf(model, u)
86  t.steps++
87}
88
89export function toRecap(t: Tally, endedAt: number, durationMs: number, reason: string, costNow?: number): EpilogueRecap {
90  const tools = FAMILY_ORDER.flatMap(f => {
91    const count = t.tools[f] ?? 0
92    return count > 0 ? [{ family: f, count }] : []
93  })
94  return {
95    id: t.turnId,
96    endedAt,
97    durationMs,
98    reason,
99    prompt: t.prompt.replace(/\s+/g, ' ').trim().slice(0, 120),
100    tools,
101    toolCount: tools.reduce((a, x) => a + x.count, 0),
102    files: t.files.map(f => baseName(f)),
103    agents: t.agents,
104    failed: t.failed.slice(0, 12),
105    tokens: tokensOf(t.usage),
106    outputTokens: t.usage.output_tokens,
107    estUsd: t.estUsd,
108    sessionDeltaUsd: costNow !== undefined && t.costAtStart !== undefined ? Math.max(0, costNow - t.costAtStart) : undefined,
109  }
110}
111
112/** `register.tsx, kz.ts +3` within `max` characters. */
113export function fileList(files: readonly string[], max: number): string {
114  let out = ''
115  let n = 0
116  for (const f of files) {
117    const next = out ? `${out}, ${f}` : f
118    const rest = files.length - n - 1
119    if (next.length + (rest > 0 ? ` +${rest}`.length : 0) > max && n > 0) break
120    out = next
121    n++
122  }
123  return n < files.length ? `${out} +${files.length - n}` : out
124}
125
126/** The prompt sent to the small model for the one-liner. */
127export function summaryPrompt(answer: string): string {
128  const clipped = answer.length > 6000 ? `${answer.slice(0, 3000)}\n…\n${answer.slice(-3000)}` : answer
129  return `Summarize what this assistant reply reports, in at most 15 words, as a plain past-tense sentence fragment with no quotes and no preamble.\n\n<reply>\n${clipped}\n</reply>`
130}
131
132/** Cleans the model's one-liner: one line, no quotes, at most 15 words. */
133export function cleanSummary(text: string): string | undefined {
134  const line = text.replace(/\s+/g, ' ').replace(/^["'“”‘’\s]+|["'“”‘’\s]+$/g, '').trim()
135  if (!line) return undefined
136  const words = line.split(' ')
137  return words.length > 15 ? `${words.slice(0, 15).join(' ')}…` : line
138}
139
types/index.d.ts 24 lines
1export type EpilogueRecap = {
2  id: string
3  endedAt: number
4  durationMs: number
5  reason: string
6  prompt: string
7  tools: { family: string; count: number }[]
8  toolCount: number
9  files: string[]
10  agents: number
11  failed: { tool: string; detail: string }[]
12  tokens: number
13  outputTokens: number
14  estUsd: number
15  sessionDeltaUsd?: number
16  summary?: string
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    epilogue: { recaps: EpilogueRecap[]; isShown: boolean; isHidden: boolean }
22  }
23}
24