A Claude Code mod (claude-mod-buddy). Bring back buddy: a terminal companion beside your status line that watches your work and talks back.

A Claude Code mod (claude-mod-buddy). Bring back buddy: a terminal companion beside your Claude Code status line. It hatches from an egg, watches your work, and says one line in its own voice after a turn. Every line is one small model call in its personality. It has no canned lines.
It needs Claude Code 2.1.287 or later. You do not need fno, but with the fno CLI it also tells you when your fleet ships work or a question waits for you.
/plugin install buddy@fno./buddy to watch it hatch and see its card. Press any key to close the card./buddy statusline to put it beside your status line./buddy and /bbb are the same command.
| Command | What it does |
|---|---|
/buddy | Show the card |
/buddy pet | Pet the buddy |
/buddy roll | Roll a new buddy (3 rerolls, refilled daily) |
/buddy statusline | Stand beside your status line |
/buddy pane | Stand in a side pane (fullscreen layout) |
/buddy off / on | Mute or unmute |
/buddy bye | Remove the buddy and restore your status line |
Every observation goes to ~/.fno/state/buddy/observations.jsonl.
The full guide: docs/guides/buddy.md.
hooks/register.ts 1005 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { type Companion, type Soul, RARITY_STARS, RARITY_THEME, STAT_NAMES, type StatName, embody, hatch, rarityColor, restore } from './companion'
4import { HATCH_FRAMES, HATCH_FRAME_MS, HATCH_MIN_ROUNDS, HATCH_WOBBLE, IDLE_SEQUENCE, PET_HEARTS, RAINBOW, renderFace, renderSprite } from './sprites'
5import { type FeedRow, addressedBy, cleanPersonality, cleanReaction, idlePrompt, lastPrompt, loudReason, type Reason, turnOutput, newsFact, newsPrompt, personalityPrompt, reactionPrompt, summarizeTurn, systemPrompt } from './voice'
6
7const TICK_MS = 500
8const BUBBLE_MS = 30_000
9// After this long with nothing said, the buddy says a canned line (no model call).
10// Quiet this long, the buddy says something of its own: one model call, like a reaction.
11const IDLE_TALK_MS = 120_000
12const PET_MS = 2_500
13// The original's gap between ordinary turn reactions.
14const REACT_GAP_MS = 30_000
15const FEED_MS = 120_000
16const FEED_WINDOW_S = 600
17const FLEET_MS = 300_000
18// A buddy that drew within this window has someone looking at it.
19const SEEN_MS = 5_000
20// The status line runs in every live session, hidden mux panes too, so a draw does not mean a
21// person is there. In an fno mux pane the mux names the panes on screen; elsewhere a key typed
22// in this session's prompt box within this window stands in for that.
23const TYPED_MS = 600_000
24// The status line wrapper drops a frame older than 45 s, so an idle frame is rewritten well before that.
25const FRAME_REFRESH_MS = 25_000
26// Each new frame starts Python in the status line, so the buddy moves only while something
27// happens: for this long after you type, a turn ends, it speaks, or you pet it. Then it holds still.
28const ANIMATE_MS = 60_000
29// While it moves, the fidget steps this often; one step per status line run would cost a Python start a second.
30const STEP_MS = 2_000
31let turnAt = -Infinity
32const PANE_ID = 'buddy'
33// The /buddy card opens here, focused, so any key closes it like the original.
34const CARD_ID = 'buddy-card'
35let cardSnap: Shown | undefined
36const PANE_COLUMNS = 24
37const WRAPPER = 'statusline.py'
38// The status line runs this first; it skips Python on ticks where nothing changed.
39const FAST = 'statusline.sh'
40// bbb: bring back buddy.
41const COMMANDS = ['buddy', 'bbb']
42
43let buddy: Companion | null = null
44let muted = false
45let tick = 0
46let bubble: { text: string; at: number } | null = null
47let pettedAt = -Infinity
48let drawnAt = -Infinity
49let typedAt = -Infinity
50// The mux's on-screen file and this session's pane in it; '' outside an fno mux pane.
51let muxVisible = ''
52let muxPane = 0
53// Null when there is no mux answer to read.
54let onScreen: boolean | null = null
55let paneDrawnAt = -Infinity
56let paneAsked = false
57let reactedAt = -Infinity
58let recent: string[] = []
59// The /buddy output row draws as the card; a fresh soul's first card plays the hatch first.
60const CARD_MARK = '\u2063'
61const HATCH_MARK = '\u2064'
62type Shown = { c: Companion; last: string; r: Rerolls; hatchAt?: number; crackAt?: number }
63const shown = new Map<string, Shown>()
64let pending: Shown | undefined
65let hatchUntil = -Infinity
66// False while a fresh soul waits for its model-written personality; the hatch holds on the wobble until then.
67let personalityDone = true
68let lastSaid = ''
69let feedSince = 0
70let feedOff = false
71let fleet = ''
72let home = ''
73// The buddy's files live in the fno state folder, or ~/.local/state/buddy without fno; never under the Claude config dir.
74let stateDir = ''
75let sessionId = ''
76let wrapped = false
77let deferred = false
78let lastFrame = ''
79let frameAt = -Infinity
80let unwrappedAt = -Infinity
81
82const buddyDir = () => `${stateDir}/state/buddy`
83const settingsPath = () => `${home}/.claude/settings.json`
84const wrapperCommand = () => `bash ${buddyDir()}/${FAST}`
85
86async function load($: EngineInterface, now: number): Promise<void> {
87 muted = (await $.store.get('muted')) === true
88 const saved = (await $.store.get('soul')) as Companion | undefined
89 if (saved?.seed) {
90 buddy = embody(saved)
91 return
92 }
93 let soul: Soul | null = await fromFno($)
94 if (soul) {
95 buddy = embody(soul)
96 return
97 }
98 try {
99 if (home) soul = restore(await $.fs.read(home + '/.claude.json'), now)
100 } catch {
101 soul = null
102 }
103 const welcome = soul ? `${soul.name} is back. did you miss me?` : null
104 const fresh = !soul
105 soul ??= hatch(newSeed(), now)
106 await $.store.set('soul', soul)
107 buddy = embody(soul)
108 say(welcome ?? `hi. i'm ${buddy.name}.`, now)
109 if (fresh) {
110 personalityDone = false
111 void givePersonality($)
112 }
113}
114
115// The soul is shared by every session on the machine. A roll or a new personality in one
116// session rewrites it; the others take it here, so all sessions show the same buddy.
117async function syncSoul($: EngineInterface): Promise<void> {
118 const saved = (await $.store.get('soul')) as Soul | undefined
119 if (!buddy || !saved?.seed) return
120 if (saved.seed === buddy.seed && saved.name === buddy.name && saved.personality === buddy.personality) return
121 if (saved.seed !== buddy.seed) {
122 recent = []
123 lastSaid = ''
124 // Drop the old buddy's line but keep its time, so the swap does not start an idle call.
125 if (bubble) bubble = { text: '', at: bubble.at }
126 }
127 buddy = embody(saved)
128}
129
130// A model call is spent only where a person can see the answer.
131function attended(now: number): boolean {
132 return onScreen ?? now - typedAt < TYPED_MS
133}
134
135async function readOnScreen($: EngineInterface): Promise<void> {
136 if (!muxVisible) return
137 try {
138 onScreen = (JSON.parse(await $.fs.read(muxVisible)).panes ?? []).includes(muxPane)
139 } catch {
140 // An older mux writes no file: fall back to typing.
141 onScreen = null
142 }
143}
144
145// Idle talk is one line for the whole machine: the first attended session past the gap says it.
146async function claimIdle($: EngineInterface, now: number): Promise<boolean> {
147 if (now - (Number(await $.store.get('idleAt')) || -Infinity) < IDLE_TALK_MS) return false
148 await $.store.set('idleAt', now)
149 return true
150}
151
152// An fno release from before the move still loads its own copy of the buddy, which stamps fno's
153// store every few minutes. While that copy runs, this one stays off so only one buddy shows.
154async function oldCopyLive($: EngineInterface, now: number): Promise<boolean> {
155 if (!home) return false
156 const dir = `${home}/.claude/plugins/store`
157 try {
158 for (const entry of await $.fs.list(dir)) {
159 if (!entry.name.startsWith('fno_') || !entry.name.endsWith('.json')) continue
160 const old = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
161 const at = Math.max(old?.fleet?.at ?? 0, old?.feed?.at ?? 0)
162 if (now - at < FLEET_MS + 60_000) return true
163 }
164 } catch {
165 // Nothing readable: no old copy to defer to.
166 }
167 return false
168}
169
170// The buddy used to load inside the fno plugin, whose store is a different file. Bring its soul,
171// reroll bank, and mute over once, so the same buddy comes back after the move.
172async function fromFno($: EngineInterface): Promise<Soul | null> {
173 if (!home) return null
174 const dir = `${home}/.claude/plugins/store`
175 try {
176 // The marketplace install (fno@fno, or fno@footnote before the rename) wins over a local dev copy such as fno@inline.
177 const market = (name: string) => Number(name.startsWith('fno_fno-') || name.startsWith('fno_footnote-'))
178 const entries = (await $.fs.list(dir)).sort((a, b) => market(b.name) - market(a.name))
179 for (const entry of entries) {
180 if (!entry.name.startsWith('fno_') || !entry.name.endsWith('.json')) continue
181 const old = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
182 if (!old?.soul?.seed) continue
183 await $.store.set('soul', old.soul)
184 if (old.rerolls) await $.store.set('rerolls', old.rerolls)
185 if (old.muted === true) {
186 muted = true
187 await $.store.set('muted', true)
188 }
189 return old.soul as Soul
190 }
191 } catch {
192 // No fno store, or an unreadable one: hatch as usual.
193 }
194 return null
195}
196
197// A new buddy hatches with a placeholder; one model call then writes who it is, as the original did.
198async function givePersonality($: EngineInterface): Promise<void> {
199 const c = buddy
200 if (!c) return
201 try {
202 const reply = await $.model.complete({ model: 'haiku', prompt: personalityPrompt(c, c.seed), maxTokens: 200, timeoutMs: 20_000 })
203 const personality = reply.isAnswered ? cleanPersonality(reply.text) : null
204 if (!personality || buddy?.seed !== c.seed) return
205 const soul: Soul = { seed: c.seed, name: c.name, personality, hatchedAt: c.hatchedAt, ...(c.species ? { species: c.species } : {}) }
206 await $.store.set('soul', soul)
207 buddy = embody(soul)
208 } catch {
209 // The placeholder personality stays; the buddy still talks.
210 } finally {
211 if (buddy?.seed === c.seed) personalityDone = true
212 }
213}
214
215function newSeed(): string {
216 const bytes = crypto.getRandomValues(new Uint8Array(8))
217 return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
218}
219
220function say(text: string, now: number): void {
221 bubble = { text, at: now }
222}
223
224// Rerolls are earned: one free a day, one per SHIPS_PER_REROLL PRs the fleet ships, banked up to REROLL_BANK.
225// ponytail: fixed numbers; a [buddy] section in config.toml can own them once someone wants to tune them.
226const REROLL_BANK = 3
227const SHIPS_PER_REROLL = 2
228export type Rerolls = { bank: number; day: string; ships: number; shipAt: number }
229
230export function refill(r: Rerolls | undefined, today: string, shippedAt: number[] = []): Rerolls {
231 let { bank, day, ships, shipAt } = r ?? { bank: 0, day: '', ships: 0, shipAt: 0 }
232 if (day !== today) {
233 bank = Math.min(REROLL_BANK, bank + 1)
234 day = today
235 }
236 // shipAt is the newest ship already counted, so every session reading the same feed counts a PR once.
237 for (const at of [...shippedAt].sort((a, b) => a - b)) {
238 if (at <= shipAt) continue
239 shipAt = at
240 ships += 1
241 if (ships >= SHIPS_PER_REROLL) {
242 ships = 0
243 bank = Math.min(REROLL_BANK, bank + 1)
244 }
245 }
246 return { bank, day, ships, shipAt }
247}
248
249const today = (now: number) => new Date(now).toLocaleDateString('en-CA')
250
251async function rerolls($: EngineInterface, now: number, shippedAt: number[] = []): Promise<Rerolls> {
252 const r = refill((await $.store.get('rerolls')) as Rerolls | undefined, today(now), shippedAt)
253 await $.store.set('rerolls', r)
254 return r
255}
256
257// The original card: rarity and species on top, the sprite, the name, the quoted personality,
258// the stat bars, and the last thing it said.
259function cardTree(ui: any, c: Companion, said: string, r: Rerolls): any {
260 const { Box, Text } = ui
261 const color = RARITY_THEME[c.rarity]
262 const stat = (s: StatName) => {
263 const v = c.stats[s]
264 const n = Math.round(v / 10)
265 return Box({ flexDirection: 'row', children: [Box({ width: 11, children: [Text({ children: [s] })] }), Text({ children: ['█'.repeat(n) + '░'.repeat(10 - n) + ' '] }), Text({ dimColor: true, children: [String(v).padStart(3)] })] })
266 }
267 return Box({
268 flexDirection: 'column',
269 borderStyle: 'round',
270 borderColor: color,
271 paddingX: 2,
272 paddingY: 1,
273 width: 40,
274 flexShrink: 0,
275 children: [
276 Box({ justifyContent: 'space-between', children: [Text({ bold: true, color, children: [`${RARITY_STARS[c.rarity]} ${c.rarity.toUpperCase()}`] }), Text({ color, children: [c.species.toUpperCase()] })] }),
277 ...(c.shiny ? [Text({ color: 'warning', bold: true, children: ['✨ SHINY ✨'] })] : []),
278 Box({ flexDirection: 'column', marginY: 1, children: drawArt(ui, c, renderSprite(c, 0)) }),
279 Text({ bold: true, children: [c.name] }),
280 Box({ marginY: 1, children: [Text({ dimColor: true, italic: true, children: [`"${c.personality}"`] })] }),
281 Box({ flexDirection: 'column', children: STAT_NAMES.map(stat) }),
282 ...(said
283 ? [Box({ flexDirection: 'column', marginTop: 1, children: [Text({ dimColor: true, children: ['last said'] }), Box({ borderStyle: 'round', borderColor: 'inactive', paddingX: 1, children: [Text({ dimColor: true, italic: true, children: [said] })] })] })]
284 : []),
285 Box({ marginTop: 1, children: [Text({ dimColor: true, children: [`rerolls ${r.bank}/${REROLL_BANK}`] })] }),
286 ],
287 })
288}
289
290// The hatch while it plays, then the card; in the focused pane, with the original's footer and a key to close.
291function showTree(ui: any, snap: Shown, now: number, close: any): any {
292 if (snap.hatchAt === undefined) return close ? ui.Box({ flexDirection: 'column', children: [cardTree(ui, snap.c, snap.last, snap.r), ui.Box({ marginTop: 1, children: [close] })] }) : cardTree(ui, snap.c, snap.last, snap.r)
293 const tick = Math.floor((now - snap.hatchAt) / HATCH_FRAME_MS)
294 // The soul is ready once the model wrote its personality, or after 8 s without one.
295 const ready = (buddy?.seed === snap.c.seed && personalityDone) || now - snap.hatchAt > 8_000
296 if (snap.crackAt === undefined && ready && tick >= HATCH_MIN_ROUNDS * HATCH_WOBBLE) snap.crackAt = tick
297 const frame = snap.crackAt === undefined ? tick % HATCH_WOBBLE : Math.min(HATCH_WOBBLE + tick - snap.crackAt, HATCH_FRAMES.length)
298 if (frame >= HATCH_FRAMES.length) {
299 const c = buddy?.seed === snap.c.seed ? buddy : snap.c
300 const said = lastSaid || snap.last
301 const { Box, Text } = ui
302 return Box({
303 flexDirection: 'column',
304 children: [
305 cardTree(ui, c, said, snap.r),
306 Box({
307 flexDirection: 'column',
308 marginTop: 1,
309 children: [
310 Text({ dimColor: true, children: [`${c.name} is here · it'll chime in as you code`] }),
311 Text({ dimColor: true, children: ['each line is one small model call on your plan'] }),
312 Text({ dimColor: true, children: ['say its name to get its take · /buddy pet · /buddy off'] }),
313 ...(close ? [Box({ marginTop: 1, children: [close] })] : []),
314 ],
315 }),
316 ],
317 })
318 }
319 const f = HATCH_FRAMES[frame]!
320 const { Box, Text } = ui
321 return Box({
322 flexDirection: 'column',
323 alignItems: 'center',
324 borderStyle: 'round',
325 borderColor: RAINBOW[tick % RAINBOW.length],
326 paddingY: 1,
327 children: [
328 ...f.lines.map(l => Text({ children: [' '.repeat(1 + f.offset) + l + ' '.repeat(1 - f.offset)] })),
329 Box({
330 flexDirection: 'column',
331 alignItems: 'center',
332 marginTop: 1,
333 children: [
334 Text({ dimColor: true, children: ['hatching a coding buddy…'] }),
335 Text({ dimColor: true, children: ["it'll watch you work and occasionally have opinions"] }),
336 ],
337 }),
338 ],
339 })
340}
341
342// What a fresh buddy sees first, as the original read it: the package name and the last commits.
343async function projectContext($: EngineInterface): Promise<string> {
344 const root = await $.session.root().catch(() => '')
345 const parts: string[] = []
346 try {
347 const pkg = JSON.parse(await $.fs.read(`${root}/package.json`))
348 if (pkg.name) parts.push(`project: ${pkg.name}${pkg.description ? ' - ' + pkg.description : ''}`)
349 } catch {
350 // No package.json.
351 }
352 const log = await $.process.run(['git', '-C', root || '.', 'log', '--oneline', '-n', '3'], { timeoutMs: 5_000 }).catch(() => null)
353 if (log?.exitCode === 0 && log.stdout.trim()) parts.push(`recent commits:\n${log.stdout.trim()}`)
354 return parts.join('\n') || '(fresh project, nothing to see yet)'
355}
356
357function rerollLine(r: Rerolls): string {
358 return `rerolls: ${r.bank}/${REROLL_BANK} · one more per ${SHIPS_PER_REROLL} shipped PRs (${r.ships}/${SHIPS_PER_REROLL}) and one each day`
359}
360
361function card(c: Companion, r: Rerolls): string {
362 const stats = STAT_NAMES.map(s => `${s.padEnd(9)} ${'█'.repeat(Math.round(c.stats[s] / 10)).padEnd(10, '░')} ${c.stats[s]}`)
363 return [
364 `${c.name} the ${c.species} ${RARITY_STARS[c.rarity]} ${c.rarity}${c.shiny ? ' ✨ shiny' : ''}`,
365 '',
366 ...renderSprite(c, 0),
367 '',
368 c.personality,
369 '',
370 ...stats,
371 '',
372 rerollLine(r),
373 '/buddy pet · roll · statusline · pane · off · bye',
374 ].join('\n')
375}
376
377async function readJson($: EngineInterface, path: string): Promise<any> {
378 try {
379 return JSON.parse(await $.fs.read(path))
380 } catch (err) {
381 if (err instanceof SyntaxError) throw err
382 return undefined
383 }
384}
385
386// The user's settings file is theirs: a file that does not parse is left alone.
387async function readSettings($: EngineInterface): Promise<Record<string, unknown> | null> {
388 try {
389 return (await readJson($, settingsPath())) ?? {}
390 } catch {
391 return null
392 }
393}
394
395function isOurs(statusLine: any): boolean {
396 return wrapperRoot(statusLine) !== undefined
397}
398
399// The state folder of the wrapper a status line runs, or undefined when it runs no buddy wrapper.
400export function wrapperRoot(statusLine: any): string | undefined {
401 if (typeof statusLine?.command !== 'string') return undefined
402 return statusLine.command.match(/(?:^|\s)(\S+)\/state\/buddy\/statusline\.(?:py|sh)(?:\s|$)/)?.[1]
403}
404
405// The wrapper reads frames from its own folder, so every session must write there. A session that
406// asks fno for the folder can time out under load and fall back to ~/.local, then write where no
407// wrapper reads; so the wrapper in the settings decides, and fno is asked only when there is none.
408async function findStateDir($: EngineInterface, settings: Record<string, unknown> | null): Promise<string> {
409 return wrapperRoot(settings?.statusLine) ?? (await resolveStateDir($))
410}
411
412// A buddy from before the shell entry point did not know statusline.sh as its own, so it could
413// save one wrapper as the user's line and wrap it again. The user's line is the one that wrapper saved.
414async function unnest($: EngineInterface): Promise<void> {
415 const path = `${buddyDir()}/inner.json`
416 const inner = await readJson($, path).catch(() => undefined)
417 const root = wrapperRoot(inner?.statusLine)
418 if (!root) return
419 const deeper = root === stateDir ? undefined : await readJson($, `${root}/state/buddy/inner.json`).catch(() => undefined)
420 // Without the line that wrapper saved there is nothing to put back, so the file stays as it is.
421 if (!deeper || !('statusLine' in deeper) || isOurs(deeper.statusLine)) return
422 await $.fs.write(path, JSON.stringify({ statusLine: deeper.statusLine }, null, 2) + '\n')
423}
424
425async function resolveStateDir($: EngineInterface): Promise<string> {
426 const fallback = home ? `${home}/.local` : ''
427 try {
428 const out = await $.process.run(['fno', 'config', 'get', 'state_dir'], { timeoutMs: 10_000 })
429 const dir = out.exitCode === 0 ? out.stdout.split('\n')[0]!.trim() : ''
430 return dir ? dir.replace(/^~(?=\/|$)/, home).replace(/\/+$/, '') : fallback
431 } catch {
432 return fallback
433 }
434}
435
436// Keeps a copy of the wrapper at a path that survives plugin updates, so statusLine never points into the plugin cache.
437async function installWrapper($: EngineInterface): Promise<void> {
438 for (const file of [WRAPPER, FAST]) {
439 const ours = await $.fs.read(`${$.plugin.root}/hooks/${file}`)
440 const target = `${buddyDir()}/${file}`
441 let theirs = ''
442 try {
443 theirs = await $.fs.read(target)
444 } catch {
445 theirs = ''
446 }
447 if (theirs !== ours) await $.fs.write(target, ours)
448 }
449}
450
451// The buddy animates only while the status line reruns every second. A slower interval set on
452// the wrapper is the user's wish for their own line, so it moves there and the wrapper keeps 1 s.
453// An install from before the shell fast path moves to it here too.
454async function keepTicking($: EngineInterface, settings: Record<string, unknown>): Promise<void> {
455 const ours = settings.statusLine as any
456 if (ours.refreshInterval === 1 && ours.command === wrapperCommand()) return
457 const inner = (await readJson($, `${buddyDir()}/inner.json`)) ?? {}
458 // An interval the user already gave their own line wins over the one on the wrapper.
459 if (inner.statusLine && inner.statusLine.refreshInterval == null && typeof ours.refreshInterval === 'number' && ours.refreshInterval !== 1) {
460 inner.statusLine.refreshInterval = ours.refreshInterval
461 await $.fs.write(`${buddyDir()}/inner.json`, JSON.stringify(inner, null, 2) + '\n')
462 }
463 settings.statusLine = { ...ours, command: wrapperCommand(), refreshInterval: 1 }
464 await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
465}
466
467async function statuslineOn($: EngineInterface): Promise<string> {
468 if (!stateDir) return 'The status line needs HOME to be set.'
469 const settings = await readSettings($)
470 if (!settings) return `${settingsPath()} does not parse, so I left it alone.`
471 const current = settings.statusLine as any
472 if (isOurs(current)) return `${buddy!.name} already sits beside your status line.`
473 await installWrapper($)
474 await $.fs.write(`${buddyDir()}/inner.json`, JSON.stringify({ statusLine: current ?? null }, null, 2) + '\n')
475 settings.statusLine = { type: 'command', command: wrapperCommand(), padding: current?.padding ?? 0, refreshInterval: 1 }
476 await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
477 wrapped = true
478 await $.ui.close({ id: PANE_ID })
479 return current
480 ? `${buddy!.name} now sits beside your status line. /buddy pane moves it to a side pane and puts yours back as it was.`
481 : `${buddy!.name} now sits in a new status line. /buddy pane moves it to a side pane and removes that line.`
482}
483
484async function statuslineOff($: EngineInterface): Promise<{ ok: boolean; text: string }> {
485 const settings = await readSettings($)
486 if (!settings) return { ok: false, text: `${settingsPath()} does not parse, so I left it alone.` }
487 const saved = stateDir ? await readJson($, `${buddyDir()}/inner.json`).catch(() => undefined) : undefined
488 let text = ''
489 if (isOurs(settings.statusLine)) {
490 // Without the saved copy there is nothing to put back, and deleting the wrapper would leave no status line at all.
491 if (!saved) return { ok: false, text: `I cannot read your saved status line in ${buddyDir()}/inner.json, so I left ${settingsPath()} alone.` }
492 if (saved.statusLine) settings.statusLine = saved.statusLine
493 else delete settings.statusLine
494 await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
495 text = 'Your status line is back as it was.'
496 }
497 // inner.json marks that the user wants the buddy beside the status line; without it no re-wrap is offered.
498 if (saved) await $.fs.write(`${buddyDir()}/inner.json`, '')
499 wrapped = false
500 unwrappedAt = await $.clock.now()
501 return { ok: true, text }
502}
503
504// The original observer: one model call per reaction. Loud turns, a mention of the name, a pet,
505// and a hatch skip the quiet gap; an ordinary turn waits it out. The last three lines ride along
506// so the buddy does not repeat itself.
507async function react($: EngineInterface, why: Reason | 'idle' = 'turn', context?: string): Promise<void> {
508 const c = buddy
509 if (!c) return
510 const messages = await $.session.messages()
511 const summary = context ?? summarizeTurn(messages)
512 if (why === 'turn' && !summary.trim()) return
513 const reply = await $.model.complete({
514 model: 'haiku',
515 system: systemPrompt(c),
516 prompt: why === 'idle' ? idlePrompt(summary) : reactionPrompt(summary, why, recent),
517 maxTokens: 160,
518 timeoutMs: 20_000,
519 })
520 const line = reply.isAnswered ? cleanReaction(reply.text) : ''
521 if (!line || buddy?.seed !== c.seed) return
522 recent = [...recent, line].slice(-3)
523 lastSaid = line
524 const now = await $.clock.now()
525 say(line, now)
526 $.ui.invalidate('ui.render')
527 await remember($, c, why, line, now)
528}
529
530// Every observation the buddy makes, one JSON row each, so you can read them back. The mods API
531// has no append, so each write rewrites the file; the cap keeps that cheap.
532// ponytail: two sessions writing at once can drop a row; an append call would fix it if the API gains one.
533const OBSERVATIONS_KEPT = 1000
534async function remember($: EngineInterface, c: Companion, why: string, line: string, now: number): Promise<void> {
535 const path = `${buddyDir()}/observations.jsonl`
536 let old: string[] = []
537 try {
538 old = (await $.fs.read(path)).split('\n').filter(Boolean)
539 } catch {}
540 // Two writes that cross can leave a torn row; drop it here so it does not stay in the file.
541 old = old.filter(row => {
542 try {
543 return JSON.parse(row) && true
544 } catch {
545 return false
546 }
547 })
548 const rows = [...old, JSON.stringify({ at: new Date(now).toISOString(), name: c.name, why, line })].slice(-OBSERVATIONS_KEPT)
549 await $.fs.write(path, rows.join('\n') + '\n').catch(() => {})
550}
551
552// One feed read serves every session on the machine: a read costs about 4 s of
553// CPU, and a mux of workers would otherwise each pay it every FEED_MS.
554async function feedRows($: EngineInterface, now: number): Promise<FeedRow[] | null> {
555 const shared = (await $.store.get('feed')) as { at: number; rows: FeedRow[] } | undefined
556 if (shared && now - shared.at < FEED_MS - 10_000) return shared.rows
557 let out
558 try {
559 const since = Math.floor(now / 1000) - FEED_WINDOW_S
560 out = await $.process.run(['fno-agents', 'feed', '--json', '--since-epoch', String(since), '--limit', '50'], {
561 timeoutMs: 15_000,
562 })
563 } catch {
564 feedOff = true
565 return null
566 }
567 if (out.exitCode !== 0) return null
568 let rows: FeedRow[]
569 try {
570 rows = JSON.parse(out.stdout)
571 } catch {
572 return null
573 }
574 await $.store.set('feed', { at: now, rows })
575 return rows
576}
577
578async function readFeed($: EngineInterface, now: number): Promise<void> {
579 if (feedOff || muted || !buddy || now - drawnAt > SEEN_MS) return
580 const rows = await feedRows($, now)
581 if (!rows) return
582 const before = refill((await $.store.get('rerolls')) as Rerolls | undefined, today(now)).bank
583 const after = await rerolls($, now, rows.filter(row => row.kind === 'node_shipped').map(row => Date.parse(row.ts)).filter(Number.isFinite))
584 // Every session counts ships, so none is missed; an attended one tells them, once for the machine.
585 if (!attended(now)) return
586 let since = Math.max(feedSince, Number(await $.store.get('newsSince')) || 0)
587 let line: string | null = null
588 for (const row of rows) {
589 const at = Math.floor(Date.parse(row.ts) / 1000)
590 if (!(at >= since)) continue
591 since = Math.max(since, at + 1)
592 line = newsFact(row) ?? line
593 }
594 feedSince = since
595 await $.store.set('newsSince', since)
596 const earned = after.bank > before ? `+1 reroll (${after.bank}/${REROLL_BANK})` : ''
597 if (line && buddy) {
598 const c = buddy
599 const reply = await $.model.complete({ model: 'haiku', system: systemPrompt(c), prompt: newsPrompt(line), maxTokens: 160, timeoutMs: 20_000 }).catch(() => null)
600 // Unvoiced, the fact still gets through: a question waiting on the user must not vanish.
601 line = (reply?.isAnswered && cleanReaction(reply.text)) || line
602 }
603 line = [line, earned].filter(Boolean).join(' ')
604 if (line) {
605 say(line, now)
606 $.ui.invalidate('ui.render')
607 if (buddy) await remember($, buddy, 'news', line, now)
608 }
609}
610
611async function runJson($: EngineInterface, argv: string[], cwd?: string): Promise<any> {
612 try {
613 const out = await $.process.run(argv, { timeoutMs: 20_000, ...(cwd ? { cwd } : {}) })
614 return out.exitCode === 0 ? JSON.parse(out.stdout) : undefined
615 } catch {
616 return undefined
617 }
618}
619
620// The same counts the fleet's own commands print: live workers from the spawn gate,
621// questions waiting on the user from the outstanding inbox, and the user's open PRs.
622// Each command takes several seconds, so one read every FLEET_MS serves every session.
623export function fleetLine(gate: any, outstanding: any, prs: any): string {
624 const parts: string[] = []
625 if (typeof gate?.live_workers === 'number') parts.push(`${gate.live_workers} workers`)
626 if (Array.isArray(outstanding?.questions)) parts.push(`${outstanding.questions.length} asks`)
627 if (Array.isArray(prs)) parts.push(`${prs.length} PRs`)
628 return parts.join(' · ')
629}
630
631async function readFleet($: EngineInterface, now: number): Promise<void> {
632 if (feedOff || muted || !buddy || now - drawnAt > SEEN_MS) return
633 const shared = (await $.store.get('fleet')) as { at: number; line: string } | undefined
634 if (shared && now - shared.at < FLEET_MS - 10_000) {
635 fleet = shared.line
636 return
637 }
638 const [gate, outstanding, prs] = await Promise.all([
639 runJson($, ['fno', 'agents', 'gate-status']),
640 runJson($, ['fno', 'inbox', 'outstanding', '--json']),
641 runJson($, ['gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--json', 'number'], await $.session.root()),
642 ])
643 fleet = fleetLine(gate, outstanding, prs)
644 await $.store.set('fleet', { at: now, line: fleet })
645}
646
647function talking(now: number): string | null {
648 return bubble?.text && now - bubble.at < BUBBLE_MS ? bubble.text : null
649}
650
651function sprite(c: Companion, now: number): string[] {
652 const lines = renderSprite(c, IDLE_SEQUENCE[Math.floor(now / STEP_MS) % IDLE_SEQUENCE.length]!)
653 // A 5-line sprite keeps row 0 for a hat; a shorter one has no free row, so the hearts go above it.
654 if (now - pettedAt < PET_MS) lines.splice(0, lines.length < 5 ? 0 : 1, PET_HEARTS[tick % PET_HEARTS.length]!)
655 return lines
656}
657
658// The wrapper stamps a heartbeat each time Python runs, so a status line set in any settings file counts.
659// The shell fast path skips Python, but a new frame or every 25th tick runs it, about every 25 s at most.
660const WRAPPER_SEEN_MS = 45_000
661async function wrapperSeen($: EngineInterface, now: number): Promise<boolean> {
662 if (!stateDir) return false
663 try {
664 const at = Number(await $.fs.read(`${buddyDir()}/frames/${sessionId}.seen`))
665 // A beat from before /buddy pane is the old wrapper's last run, not a live one.
666 return at > unwrappedAt && now - at < WRAPPER_SEEN_MS
667 } catch {
668 return false
669 }
670}
671
672// The status line wrapper reads this file; frames change on screen at each status line refresh.
673// A pane the mux hides never moves; anywhere else the buddy moves for ANIMATE_MS after activity.
674function moving(now: number): boolean {
675 const last = Math.max(typedAt, turnAt, bubble?.at ?? -Infinity, pettedAt, hatchUntil)
676 return onScreen !== false && now - last < ANIMATE_MS
677}
678
679async function writeFrame($: EngineInterface, now: number): Promise<void> {
680 if (!buddy || !sessionId || !stateDir) return
681 const frame = JSON.stringify({
682 // A pane the mux hides holds still, so its status line can reuse its last output.
683 sprite: moving(now) ? sprite(buddy, now) : renderSprite(buddy, 0),
684 name: buddy.name,
685 face: renderFace(buddy),
686 color: rarityColor(theme, buddy.rarity),
687 speech: talking(now) ?? '',
688 fleet,
689 })
690 if (frame === lastFrame && now - frameAt < FRAME_REFRESH_MS) return
691 lastFrame = frame
692 frameAt = now
693 await $.fs.write(`${buddyDir()}/frames/${sessionId}.json`, `{"at":${now},${frame.slice(1)}`)
694}
695
696const BUBBLE_COLUMNS = 34
697
698// Desktop sets text in a proportional font, which collapses the spaces in a sprite. There the
699// sprite is an SVG in a monospace font; SVG cannot read theme keys, so it takes the theme's value.
700let desktop = false
701// A terminal docks a pane on the right only in a wide fullscreen layout; anywhere else it opens above the prompt.
702let docks = false
703const DOCK_COLUMNS = 110
704// Claude Code's theme setting, so the status line and the SVG draw the color the card draws.
705let theme = 'dark'
706const svgColor = (c: Companion) => {
707 const color = rarityColor(theme, c.rarity)
708 return color.startsWith('rgb') ? color : rarityColor('dark', c.rarity)
709}
710const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
711function drawArt(ui: any, c: Companion, lines: string[]): any[] {
712 if (!desktop) return lines.map(line => ui.Text({ color: RARITY_THEME[c.rarity], children: [line] }))
713 const w = Math.ceil(Math.max(...lines.map(l => l.length)) * 8.4) + 2
714 const h = lines.length * 17
715 const rows = lines.map((l, i) => `<text x="0" y="${i * 17 + 13}" xml:space="preserve">${esc(l)}</text>`).join('')
716 return [ui.Svg({ alt: `${c.name} the ${c.species}`, width: w, height: h, source: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" font-family="ui-monospace,Menlo,monospace" font-size="14" fill="${svgColor(c)}">${rows}</svg>` })]
717}
718
719export function register(on: On) {
720 on('session.start', async ($, e, next) => {
721 const now = await $.clock.now()
722 feedSince = Math.floor(now / 1000)
723 home = (await $.env.get('HOME')) ?? ''
724 sessionId = await $.session.id()
725 const themeRow = (await $.config.list().catch(() => undefined))?.find(row => row.key === 'theme')
726 if (typeof themeRow?.value === 'string') theme = themeRow.value
727 await load($, now)
728 deferred = await oldCopyLive($, now)
729 if (deferred) muted = true
730 // A buddy that is off runs nothing at start: no process, no settings read.
731 if (!muted) {
732 const settings = await readSettings($)
733 stateDir = await findStateDir($, settings)
734 // The fno mux sets both in each pane it hosts, and writes <mux dir>/<session>.visible.json.
735 const mux = await $.env.get('FNO_SESSION')
736 muxPane = Number(await $.env.get('FNO_PANE')) || 0
737 if (mux && muxPane && stateDir) muxVisible = `${(await $.env.get('FNO_MUX_DIR')) || `${stateDir}/mux`}/${mux}.visible.json`
738 wrapped = isOurs(settings?.statusLine)
739 if (wrapped && stateDir) {
740 await installWrapper($).catch(() => {})
741 await unnest($).catch(() => {})
742 await keepTicking($, settings!).catch(() => {})
743 }
744 else if (stateDir) {
745 const saved = await readJson($, `${buddyDir()}/inner.json`).catch(() => undefined)
746 // The user wrapped once, then ran /statusline again: ask, never re-wrap on their behalf.
747 if (saved && buddy) say(`your status line changed. /buddy statusline puts me back beside it.`, now)
748 }
749 }
750 $.clock.every(TICK_MS, async () => {
751 tick += 1
752 if (!buddy || muted) return
753 const at = await $.clock.now()
754 if (tick % 4 === 0) {
755 await readOnScreen($)
756 if (at - drawnAt < SEEN_MS && at - (bubble?.at ?? -Infinity) > IDLE_TALK_MS && attended(at) && (await claimIdle($, at))) {
757 // Stamp first so a slow call is not asked twice; the line shows when it arrives.
758 bubble = { text: '', at }
759 react($, 'idle').catch(() => {})
760 }
761 await syncSoul($).catch(() => {})
762 const was = wrapped
763 const line = (await readSettings($))?.statusLine
764 // Another session or the user can point the status line at another folder; follow it.
765 stateDir = wrapperRoot(line) ?? stateDir
766 wrapped = (await wrapperSeen($, at)) || isOurs(line)
767 if (wrapped && !was) await $.ui.close({ id: PANE_ID }).catch(() => {})
768 if (wrapped) $.ui.invalidate('ui.render')
769 }
770 if (wrapped) {
771 drawnAt = at
772 await writeFrame($, at).catch(() => {})
773 } else if (at - drawnAt < SEEN_MS) $.ui.invalidate('ui.render')
774 })
775 $.clock.every(HATCH_FRAME_MS, async () => {
776 if ((await $.clock.now()) < hatchUntil) $.ui.invalidate('ui.render')
777 })
778 $.clock.every(FEED_MS, async () => readFeed($, await $.clock.now()))
779 $.clock.every(FLEET_MS / 5, async () => readFleet($, await $.clock.now()))
780 for (const name of COMMANDS) {
781 try {
782 await $.command.register({ name, description: name === 'bbb' ? 'Bring back buddy: your terminal companion' : 'Your terminal companion: show it, pet it, roll a new one, or turn it off', argumentHint: '[pet|roll|statusline|pane|off|on|bye]', immediate: true })
783 } catch {
784 // A newer Claude Code may ship its own /buddy again; /bbb still works.
785 }
786 }
787 return next(e)
788 })
789
790 for (const command of COMMANDS) on('command.run', { command }, async ($, e) => {
791 const now = await $.clock.now()
792 const arg = e.args.trim().toLowerCase()
793 if (!buddy) await load($, now)
794 if (deferred) return { text: 'Your fno plugin still runs its own buddy. Update fno (/plugin update fno@fno, or fno@footnote on an older install), then start a new session.' }
795 if (!stateDir) stateDir = await findStateDir($, await readSettings($))
796 if (arg === 'statusline') return { text: await statuslineOn($) }
797 if (arg === 'bye') {
798 const { ok, text } = await statuslineOff($)
799 if (!ok) return { text }
800 muted = true
801 await $.store.set('muted', true)
802 await $.ui.close({ id: PANE_ID }).catch(() => {})
803 $.ui.invalidate('ui.render')
804 return { text: `${text ? text + ' ' : ''}Bye from ${buddy!.name}. It is gone from every session and makes no calls. /buddy on brings it back.` }
805 }
806 if (arg === 'pane' || arg === 'restore') {
807 const { ok, text } = await statuslineOff($)
808 if (!ok) return { text }
809 // Asked for by name, so the pane opens from 110 columns rather than the unasked 144.
810 paneAsked = true
811 await $.ui.open({ id: PANE_ID, title: buddy!.name, columns: PANE_COLUMNS }).catch(() => {})
812 return { text: `${text ? text + ' ' : ''}${buddy!.name} moved to a side pane. /buddy statusline brings it back.` }
813 }
814 if (arg === 'off') {
815 muted = true
816 await $.store.set('muted', true)
817 await $.ui.close({ id: PANE_ID })
818 $.ui.invalidate('ui.render')
819 return { text: `${buddy!.name} is napping. /buddy on wakes it.` }
820 }
821 if (arg === 'on') {
822 muted = false
823 paneAsked = false
824 await $.store.set('muted', false)
825 }
826 if (arg === 'roll') {
827 const r = await rerolls($, now)
828 if (r.bank < 1) return { text: `${buddy!.name} stays. ${rerollLine(r)}.` }
829 await $.store.set('rerolls', { ...r, bank: r.bank - 1 })
830 let soul = hatch(newSeed(), now)
831 for (let i = 0; i < 20 && soul.name === buddy!.name; i++) soul = hatch(newSeed(), now)
832 await $.store.set('soul', soul)
833 buddy = embody(soul)
834 recent = []
835 lastSaid = ''
836 personalityDone = false
837 void givePersonality($)
838 }
839 if (arg === 'pet') {
840 pettedAt = now
841 reactedAt = now
842 react($, 'pet', '(you were just petted)').catch(() => {})
843 }
844 const r = await rerolls($, now)
845 const fresh = (await $.store.get('hatchSeen')) !== buddy!.seed
846 if (fresh) {
847 await $.store.set('hatchSeen', buddy!.seed)
848 hatchUntil = now + 20_000
849 // Like the original, the hello comes once the soul is written, so it speaks as itself.
850 void (async () => {
851 for (let i = 0; i < 16 && !personalityDone; i++) await $.clock.sleep(500)
852 await react($, 'hatch', await projectContext($))
853 })().catch(() => {})
854 }
855 const snap: Shown = { c: buddy!, last: lastSaid, r, ...(fresh ? { hatchAt: now } : {}) }
856 try {
857 // Above the prompt the card would push the transcript away, so there it draws in the transcript, as the original's did.
858 if (!desktop && !docks) throw new Error('no dock')
859 cardSnap = snap
860 await $.ui.open({ id: CARD_ID, title: buddy!.name, focus: true, closeOnEscape: true })
861 $.ui.invalidate('ui.render')
862 return { text: `${buddy!.name} the ${buddy!.species} · ${RARITY_STARS[buddy!.rarity]} ${buddy!.rarity}` }
863 } catch {
864 // No docked pane here (a terminal that is not wide and fullscreen): the card draws in the output row instead.
865 cardSnap = undefined
866 pending = snap
867 $.ui.invalidate('ui.render')
868 return { text: (fresh ? HATCH_MARK : CARD_MARK) + card(buddy!, r) }
869 }
870 })
871
872 on('config.set', { key: 'theme' }, async ($, e, next) => {
873 if (typeof e.value === 'string') theme = e.value
874 return next(e)
875 })
876
877 on('prompt.edit', async ($, e, next) => {
878 typedAt = await $.clock.now()
879 return next(e)
880 })
881
882 on('turn.complete', async ($, e, next) => {
883 if (!e.agentId) turnAt = await $.clock.now()
884 if (buddy && !muted && !e.agentId && !e.isAborted) {
885 const now = await $.clock.now()
886 if (now - drawnAt < SEEN_MS && attended(now)) {
887 const messages = await $.session.messages()
888 const why: Reason = addressedBy(lastPrompt(messages), buddy.name) ? 'addressed' : loudReason(turnOutput(messages)) ?? 'turn'
889 if (why !== 'turn' || now - reactedAt >= REACT_GAP_MS) {
890 reactedAt = now
891 // Not awaited: the next prompt must not wait on the buddy's model call.
892 react($, why).catch(() => {})
893 }
894 }
895 }
896 return next(e)
897 })
898
899 // The /buddy row: the card the original drew, or the hatch that leads into it.
900 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
901 desktop = e.surface === 'desktop'
902 const text = String(e.props.text ?? '')
903 if (!text.startsWith(CARD_MARK) && !text.startsWith(HATCH_MARK)) return next(e)
904 let snap = shown.get(e.requestId)
905 if (!snap) {
906 if (!pending) return next(e)
907 snap = pending
908 pending = undefined
909 shown.set(e.requestId, snap)
910 }
911 return showTree($.ui.resolve(e), snap, await $.clock.now(), false)
912 })
913
914 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
915 desktop = e.surface === 'desktop'
916 if (e.requestId !== CARD_ID || !cardSnap) return next(e)
917 const ui = $.ui.resolve(e)
918 const shut = () => {
919 cardSnap = undefined
920 void $.ui.close({ id: CARD_ID }).catch(() => {})
921 }
922 // An empty field holds the focus: any typed key or Enter closes the card, and Esc does too.
923 // Desktop draws an Input as a text box, so there the card closes with a button or Esc.
924 const close = e.surface === 'desktop'
925 ? ui.Button({ key: 'close', label: 'close', onPress: shut })
926 : ui.Input({ key: 'close', placeholder: 'press any key', value: '', submitLabel: 'close', autoFocus: true, onInput: shut, onSubmit: shut })
927 return showTree(ui, cardSnap, await $.clock.now(), close)
928 })
929
930 // The fallback when the status line is not wrapped: a narrow dock on the right,
931 // the buddy standing at the bottom and its words above it.
932 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
933 desktop = e.surface === 'desktop'
934 if (e.requestId !== PANE_ID || !buddy || muted) return next(e)
935 // One buddy on screen: a pane left open (a resumed session, a late open) closes once the status line has it.
936 if (wrapped) {
937 void $.ui.close({ id: PANE_ID }).catch(() => {})
938 return next(e)
939 }
940 const now = await $.clock.now()
941 drawnAt = paneDrawnAt = now
942 const { Box, Text, Button } = $.ui.resolve(e)
943 const color = RARITY_THEME[buddy.rarity]
944 const words = talking(now)
945 return Box({
946 flexDirection: 'column',
947 justifyContent: 'flex-end',
948 height: e.props.scroll?.bodyRows ?? 12,
949 children: [
950 ...(words ? [Box({ borderStyle: 'round', children: [Text({ wrap: 'wrap', children: [words] })] }), Box({ justifyContent: 'center', children: [Text({ children: ['◦ ·'] })] })] : []),
951 // The bubble takes the full width; the fleet line, the buddy, and its name stand centered under it.
952 Box({
953 flexDirection: 'column',
954 alignItems: 'center',
955 children: [
956 ...(fleet ? [Text({ dimColor: true, wrap: 'wrap', children: [fleet] }), Text({ children: [' '] })] : []),
957 ...drawArt({ Text, Svg: $.ui.resolve(e).Svg }, buddy, sprite(buddy, now)),
958 Button({ key: 'pet', label: buddy.name, hotkey: 'p', plain: true, dimColor: true, onPress: async () => {
959 pettedAt = await $.clock.now()
960 $.ui.invalidate('ui.render')
961 } }),
962 ],
963 }),
964 ],
965 })
966 })
967
968 // The band only holds a one-line face, and only where neither the status line nor the dock has the buddy.
969 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
970 desktop = e.surface === 'desktop'
971 docks = e.viewport?.isFullscreen === true && (e.viewport?.columns ?? 0) >= DOCK_COLUMNS
972 // Desktop draws no status line but shares its settings, so a wrapped status line hides nothing there.
973 if (!buddy || muted || (wrapped && e.surface !== 'desktop') || e.props.hasSurvey) return next(e)
974 const now = await $.clock.now()
975 if (now - paneDrawnAt < SEEN_MS) return next(e)
976 if (!paneAsked && e.viewport?.isFullscreen === true) {
977 paneAsked = true
978 void $.ui.open({ id: PANE_ID, title: buddy.name, columns: PANE_COLUMNS }).catch(() => {})
979 }
980 drawnAt = now
981 const { Box, Text } = $.ui.resolve(e)
982 const words = talking(now)
983 const art = sprite(buddy, now)
984 // Desktop has room above the input: the full buddy stands at the right edge, its bubble to its left.
985 if (e.surface === 'desktop' && (e.props.maxRows ?? 0) > art.length) {
986 const color = RARITY_THEME[buddy.rarity]
987 const ours = Box({
988 flexDirection: 'row',
989 justifyContent: 'flex-end',
990 alignItems: 'flex-end',
991 children: [
992 ...(words ? [Box({ borderStyle: 'round', width: BUBBLE_COLUMNS, children: [Text({ wrap: 'wrap', children: [words] })] }), Text({ children: [' ◦ · '] })] : []),
993 Box({ flexDirection: 'column', alignItems: 'center', children: [...drawArt({ Text, Svg: $.ui.resolve(e).Svg }, buddy, art), Text({ bold: true, children: [buddy.name] })] }),
994 ],
995 })
996 const theirs = await next(e)
997 return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
998 }
999 const face = (now - pettedAt < PET_MS ? '♥ ' : '') + renderFace(buddy)
1000 const ours = Text({ children: [Text({ color: RARITY_THEME[buddy.rarity], children: [`${face} ${buddy.name}`] }), ...(words ? [`: ${words}`] : [])] })
1001 const theirs = await next(e)
1002 return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
1003 })
1004}
1005hooks/companion.ts 185 lines1export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const
2export type Rarity = (typeof RARITIES)[number]
3
4export const SPECIES = [
5 'duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl',
6 'penguin', 'turtle', 'snail', 'ghost', 'axolotl', 'capybara',
7 'cactus', 'robot', 'rabbit', 'mushroom', 'chonk',
8] as const
9export type Species = (typeof SPECIES)[number]
10
11export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
12export const HATS = ['none', 'role', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck'] as const
13export type Hat = (typeof HATS)[number]
14
15export const STAT_NAMES = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'] as const
16export type StatName = (typeof STAT_NAMES)[number]
17
18export type Bones = {
19 rarity: Rarity
20 species: Species
21 eye: string
22 hat: Hat
23 shiny: boolean
24 stats: Record<StatName, number>
25}
26
27// What the store keeps. Bones are derived from the seed on every load.
28export type Soul = {
29 seed: string
30 name: string
31 personality: string
32 hatchedAt: number
33 species?: Species
34}
35
36export type Companion = Bones & Soul
37
38const RARITY_WEIGHTS: Record<Rarity, number> = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 }
39const RARITY_FLOOR: Record<Rarity, number> = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 }
40export const RARITY_STARS: Record<Rarity, string> = {
41 common: '★', uncommon: '★★', rare: '★★★', epic: '★★★★', legendary: '★★★★★',
42}
43// The original's theme keys: Claude Code draws them in the user's theme.
44export const RARITY_THEME: Record<Rarity, string> = {
45 common: 'inactive', uncommon: 'success', rare: 'permission', epic: 'autoAccept', legendary: 'warning',
46}
47
48// The status line and a Desktop SVG cannot name a theme key, so they take the value Claude Code
49// gives that key in each built-in theme (copied from Claude Code 2.1.294). A custom theme draws as dark.
50type ThemeKey = (typeof RARITY_THEME)[Rarity]
51const THEMES: Record<string, Record<ThemeKey, string>> = {
52 dark: { inactive: 'rgb(153,153,153)', success: 'rgb(78,186,101)', permission: 'rgb(177,185,249)', autoAccept: 'rgb(175,135,255)', warning: 'rgb(255,193,7)' },
53 light: { inactive: 'rgb(102,102,102)', success: 'rgb(44,122,57)', permission: 'rgb(87,105,247)', autoAccept: 'rgb(135,0,255)', warning: 'rgb(150,108,30)' },
54 'dark-daltonized': { inactive: 'rgb(153,153,153)', success: 'rgb(51,153,255)', permission: 'rgb(153,204,255)', autoAccept: 'rgb(175,135,255)', warning: 'rgb(255,204,0)' },
55 'light-daltonized': { inactive: 'rgb(102,102,102)', success: 'rgb(0,102,153)', permission: 'rgb(51,102,255)', autoAccept: 'rgb(135,0,255)', warning: 'rgb(255,153,0)' },
56 'dark-ansi': { inactive: 'ansi:white', success: 'ansi:greenBright', permission: 'ansi:blueBright', autoAccept: 'ansi:magentaBright', warning: 'ansi:yellowBright' },
57 'light-ansi': { inactive: 'ansi:blackBright', success: 'ansi:green', permission: 'ansi:blue', autoAccept: 'ansi:magenta', warning: 'ansi:yellow' },
58}
59
60export function rarityColor(theme: string, rarity: Rarity): string {
61 return (THEMES[theme] ?? THEMES.dark!)[RARITY_THEME[rarity]]!
62}
63
64function mulberry32(seed: number): () => number {
65 let a = seed >>> 0
66 return () => {
67 a = (a + 0x6d2b79f5) | 0
68 let t = Math.imul(a ^ (a >>> 15), 1 | a)
69 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
70 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
71 }
72}
73
74// FNV-1a. The old in-binary roll used Bun.hash, which a mod cannot count on.
75function hash(s: string): number {
76 let h = 2166136261
77 for (let i = 0; i < s.length; i++) {
78 h ^= s.charCodeAt(i)
79 h = Math.imul(h, 16777619)
80 }
81 return h >>> 0
82}
83
84function pick<T>(rng: () => number, arr: readonly T[]): T {
85 return arr[Math.floor(rng() * arr.length)]!
86}
87
88function rollRarity(rng: () => number): Rarity {
89 let roll = rng() * 100
90 for (const rarity of RARITIES) {
91 roll -= RARITY_WEIGHTS[rarity]
92 if (roll < 0) return rarity
93 }
94 return 'common'
95}
96
97function rollStats(rng: () => number, rarity: Rarity): Record<StatName, number> {
98 const floor = RARITY_FLOOR[rarity]
99 const peak = pick(rng, STAT_NAMES)
100 let dump = pick(rng, STAT_NAMES)
101 while (dump === peak) dump = pick(rng, STAT_NAMES)
102 const stats = {} as Record<StatName, number>
103 for (const name of STAT_NAMES) {
104 if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
105 else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
106 else stats[name] = floor + Math.floor(rng() * 40)
107 }
108 return stats
109}
110
111const NAMES_COMMON = [
112 'Pip', 'Dot', 'Bun', 'Fig', 'Mop', 'Twig', 'Puck', 'Nub', 'Wren', 'Kit', 'Roo', 'Dew',
113 'Tuft', 'Midge', 'Spud', 'Moss', 'Plop', 'Bean', 'Acorn', 'Pebble', 'Sprout', 'Maple',
114 'Clover', 'Pepper', 'Olive', 'Hazel', 'Cricket', 'Mochi', 'Tofu', 'Waffle', 'Nugget',
115 'Dumpling', 'Biscuit', 'Noodle', 'Pickles', 'Turnip', 'Basil', 'Ginger', 'Pudding',
116 'Peanut', 'Truffle', 'Toffee', 'Fudge', 'Wobble', 'Ziggy', 'Miso', 'Nacho', 'Churro',
117 'Pretzel', 'Crouton', 'Poppy', 'Fern', 'Juniper', 'Scout', 'Rascal', 'Gizmo', 'Widget',
118 'Pixel', 'Byte', 'Glitch', 'Blip', 'Spark', 'Puddle', 'Drizzle', 'Nimbus', 'Smudge', 'Freckle',
119]
120const NAMES_RARE = [
121 'Ember', 'Flint', 'Storm', 'Rune', 'Onyx', 'Sable', 'Dusk', 'Echo', 'Frost', 'Luna',
122 'Zephyr', 'Vesper', 'Corvid', 'Bramble', 'Tempest', 'Indigo', 'Cobalt', 'Jasper', 'Opal',
123 'Quartz', 'Slate', 'Aurora', 'Zenith', 'Meridian', 'Cadence', 'Lyric', 'Hemlock', 'Raven',
124 'Kestrel', 'Cinder', 'Kindle', 'Torrent', 'Riptide',
125]
126const NAMES_LEGENDARY = [
127 'Motley', 'Axiom', 'Cipher', 'Paradox', 'Catalyst', 'Oracle', 'Vortex', 'Nexus', 'Phantom',
128 'Eclipse', 'Chimera', 'Harbinger', 'Aegis', 'Entropy', 'Parallax', 'Theorem', 'Prism',
129 'Helix', 'Quasar', 'Nova', 'Maelstrom', 'Leviathan', 'Phoenix', 'Basilisk', 'Kraken', 'Umbra',
130]
131
132function rollName(rng: () => number, rarity: Rarity): string {
133 if (rarity === 'legendary' || rarity === 'epic') return pick(rng, NAMES_LEGENDARY)
134 if (rarity === 'rare' || rarity === 'uncommon') return pick(rng, [...NAMES_COMMON, ...NAMES_RARE])
135 return pick(rng, NAMES_COMMON)
136}
137
138export function rollBones(seed: string): Bones & { name: string } {
139 const rng = mulberry32(hash(seed))
140 const rarity = rollRarity(rng)
141 const bones: Bones = {
142 rarity,
143 species: pick(rng, SPECIES),
144 eye: pick(rng, EYES),
145 hat: rarity === 'common' ? 'none' : pick(rng, HATS),
146 shiny: rng() < 0.01,
147 stats: rollStats(rng, rarity),
148 }
149 return { ...bones, name: rollName(rng, rarity) }
150}
151
152export function hatch(seed: string, now: number): Soul {
153 const { name, species } = rollBones(seed)
154 return { seed, name, personality: `a ${species} with strong opinions`, hatchedAt: now }
155}
156
157// A buddy hatched by Claude Code before v2.1.97 left its name and personality in
158// ~/.claude.json. Its bones came from a hash a mod cannot reproduce, so the species
159// is read back from the personality text when it names one.
160export function restore(claudeJson: string, now: number): Soul | null {
161 let config: any
162 try {
163 config = JSON.parse(claudeJson)
164 } catch {
165 return null
166 }
167 const old = config?.companion
168 if (typeof old?.name !== 'string' || !old.name) return null
169 const personality = typeof old.personality === 'string' ? old.personality : ''
170 const words = personality.toLowerCase().match(/[a-z]+/g) ?? []
171 const species = SPECIES.find(s => words.includes(s))
172 const seed = String(config.oauthAccount?.accountUuid ?? config.userID ?? old.name)
173 return { seed, name: old.name, personality, hatchedAt: Number(old.hatchedAt) || now, ...(species ? { species } : {}) }
174}
175
176export function embody(soul: Soul): Companion {
177 const { name: _rolledName, ...bones } = rollBones(soul.seed)
178 return { ...bones, ...soul, species: soul.species ?? bones.species }
179}
180
181// The stat that rises highest above the rest decides the default voice.
182export function peakStat(c: Bones): StatName {
183 return STAT_NAMES.reduce((a, b) => (c.stats[b] > c.stats[a] ? b : a))
184}
185hooks/sprites.ts 178 lines1import type { Bones, Hat, Species } from './companion'
2
3// Each sprite is 5 lines tall, 12 wide (after {E} -> 1char substitution).
4// Multiple frames per species for idle fidget animation.
5// Line 0 is the hat slot.
6const BODIES: Record<Species, string[][]> = {
7 duck: [
8 [' ', ' __ ', ' <({E} )___ ', ' ( ._> ', ' `--\' '],
9 [' ', ' __ ', ' <({E} )___ ', ' ( ._> ', ' `--\'~ '],
10 [' ', ' __ ', ' <({E} )___ ', ' ( .__> ', ' `--\' '],
11 [' ', ' __ ', ' <({E} )___ ', ' ( ._> ', ' ~`--\' '],
12 [' ', ' __ ', ' <({E} )__ ', ' ( ._> ', ' `--\' '],
13 ],
14 goose: [
15 [' ', ' ({E}> ', ' || ', ' _(__)_ ', ' ^^^^ '],
16 [' ', ' ({E}> ', ' || ', ' _(__)_ ', ' ^^^^ '],
17 [' ', ' ({E}>> ', ' || ', ' _(__)_ ', ' ^^^^ '],
18 ],
19 blob: [
20 [' ', ' .----. ', ' ( {E} {E} ) ', ' ( ) ', ' `----\' '],
21 [' ', ' .------. ', ' ( {E} {E} ) ', ' ( ) ', ' `------\' '],
22 [' ', ' .--. ', ' ({E} {E}) ', ' ( ) ', ' `--\' '],
23 [' ', ' .----. ', ' ( {E} {E} ) ', ' ( .__. ) ', ' `----\' '],
24 [' ', ' .------. ', ' ( {E} {E} ) ', ' ( __ ) ', ' `------\' '],
25 ],
26 cat: [
27 [' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( w ) ', ' (")_(") '],
28 [' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( w ) ', ' (")_(")~ '],
29 [' ', ' /\\-/\\ ', ' ( {E} {E}) ', ' ( w ) ', ' (")_(") '],
30 [' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( w ) ', ' ~(")_(") '],
31 [' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( w ) ', ' (")_(") '],
32 ],
33 dragon: [
34 [' ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ~~ ) ', ' `-vvvv-\' '],
35 [' ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ) ', ' `-vvvv-\' '],
36 [' ~ ~ ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ~~ ) ', ' `-vvvv-\' '],
37 [' ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ~^^~ ) ', ' `-vvvv-\' '],
38 [' ~ ~ ', ' /^\\ /^\\ ', ' < {E} {E} > ', ' ( ~~ ) ', ' `-vvvv-\' '],
39 ],
40 octopus: [
41 [' ', ' .----. ', ' ( {E} {E} ) ', ' (______) ', ' /\\/\\/\\/\\ '],
42 [' ', ' .----. ', ' ( {E} {E} ) ', ' (______) ', ' \\/\\/\\/\\/ '],
43 [' o ', ' .----. ', ' ( {E} {E} ) ', ' (______) ', ' /\\/\\/\\/\\ '],
44 ],
45 owl: [
46 [' ', ' /\\ /\\ ', ' (({E})({E})) ', ' ( >< ) ', ' `----\' '],
47 [' ', ' /\\ /\\ ', ' (({E})({E})) ', ' ( >< ) ', ' .----. '],
48 [' ', ' /\\ /\\ ', ' (({E})(-)) ', ' ( >< ) ', ' `----\' '],
49 ],
50 penguin: [
51 [' ', ' .---. ', ' ({E}>{E}) ', ' /( )\\ ', ' `---\' '],
52 [' ', ' .---. ', ' ({E}>{E}) ', ' |( )| ', ' `---\' '],
53 [' .---. ', ' ({E}>{E}) ', ' /( )\\ ', ' `---\' ', ' ~ ~ '],
54 [' ', ' .---. ', ' ({E}>{E}) ', ' \\( )/ ', ' `---\' '],
55 [' ', ' .---. ', ' ({E}>{E}) ', ' /( )\\ ', ' `---\' '],
56 ],
57 turtle: [
58 [' ', ' _,--._ ', ' ( {E} {E} ) ', ' /[______]\\ ', ' `` `` '],
59 [' ', ' _,--._ ', ' ( {E} {E} ) ', ' /[______]\\ ', ' `` `` '],
60 [' ', ' _,--._ ', ' ( {E} {E} ) ', ' /[======]\\ ', ' `` `` '],
61 ],
62 snail: [
63 [' ', ' {E} .--. ', ' \\ ( @ ) ', ' \\_`--\' ', ' ~~~~~~~ '],
64 [' ', ' {E} .--. ', ' | ( @ ) ', ' \\_`--\' ', ' ~~~~~~~ '],
65 [' ', ' {E} .--. ', ' \\ ( @ ) ', ' \\_`--\' ', ' ~~~~~~ '],
66 ],
67 ghost: [
68 [' ', ' .----. ', ' / {E} {E} \\ ', ' | | ', ' ~`~``~`~ '],
69 [' ', ' .----. ', ' / {E} {E} \\ ', ' | | ', ' `~`~~`~` '],
70 [' ~ ~ ', ' .----. ', ' / {E} {E} \\ ', ' | | ', ' ~~`~~`~~ '],
71 [' ', ' .----. ', ' / {E} {E} \\ ', ' | __ | ', ' ~`~``~`~ '],
72 [' ~ ', ' .----. ', ' / {E} {E} \\ ', ' | | ', ' `~~``~~` '],
73 ],
74 axolotl: [
75 [' ', '}~(______)~{', '}~({E} .. {E})~{', ' ( .--. ) ', ' (_/ \\_) '],
76 [' ', '~}(______){~', '~}({E} .. {E}){~', ' ( .--. ) ', ' (_/ \\_) '],
77 [' ', '}~(______)~{', '}~({E} .. {E})~{', ' ( -- ) ', ' ~_/ \\_~ '],
78 ],
79 capybara: [
80 [' ', ' n______n ', ' ( {E} {E} ) ', ' ( oo ) ', ' `------\' '],
81 [' ', ' n______n ', ' ( {E} {E} ) ', ' ( Oo ) ', ' `------\' '],
82 [' ~ ~ ', ' u______n ', ' ( {E} {E} ) ', ' ( oo ) ', ' `------\' '],
83 ],
84 cactus: [
85 [' ', ' n ____ n ', ' | |{E} {E}| | ', ' |_| |_| ', ' | | '],
86 [' ', ' ____ ', ' n |{E} {E}| n ', ' |_| |_| ', ' | | '],
87 [' n n ', ' | ____ | ', ' | |{E} {E}| | ', ' |_| |_| ', ' | | '],
88 ],
89 robot: [
90 [' ', ' .[||]. ', ' [ {E} {E} ] ', ' [ ==== ] ', ' `------\' '],
91 [' ', ' .[||]. ', ' [ {E} {E} ] ', ' [ -==- ] ', ' `------\' '],
92 [' * ', ' .[||]. ', ' [ {E} {E} ] ', ' [ ==== ] ', ' `------\' '],
93 ],
94 rabbit: [
95 [' ', ' (\\__/) ', ' ( {E} {E} ) ', ' =( .. )= ', ' (")__(") '],
96 [' ', ' (|__/) ', ' ( {E} {E} ) ', ' =( .. )= ', ' (")__(") '],
97 [' ', ' (\\__/) ', ' ( {E} {E} ) ', ' =( . . )= ', ' (")__(") '],
98 ],
99 mushroom: [
100 [' ', ' .-o-OO-o-. ', '(__________)', ' |{E} {E}| ', ' |____| '],
101 [' ', ' .-O-oo-O-. ', '(__________)', ' |{E} {E}| ', ' |____| '],
102 [' . o . ', ' .-o-OO-o-. ', '(__________)', ' |{E} {E}| ', ' |____| '],
103 ],
104 chonk: [
105 [' ', ' /\\ /\\ ', ' ( {E} {E} ) ', ' ( .. ) ', ' `------\' '],
106 [' ', ' /\\ /| ', ' ( {E} {E} ) ', ' ( .. ) ', ' `------\' '],
107 [' ', ' /\\ /\\ ', ' ( {E} {E} ) ', ' ( .. ) ', ' `------\'~ '],
108 ],
109}
110
111const HAT_LINES: Record<Hat, string> = {
112 none: '',
113 role: ' \\^^^/ ',
114 tophat: ' [___] ',
115 propeller: ' -+- ',
116 halo: ' ( ) ',
117 wizard: ' /^\\ ',
118 beanie: ' (___) ',
119 tinyduck: ' ,> ',
120}
121
122// The fidget loop: a frame change on most steps, with a blink (-1) now and then. A mostly-resting
123// loop, like the original's, looked frozen in the status line.
124export const IDLE_SEQUENCE = [0, 1, 0, 2, 3, -1, 0, 4, 1, 2, 0, -1, 3, 4, 2, 1]
125
126export function renderSprite(bones: Bones, frame = 0): string[] {
127 const frames = BODIES[bones.species]
128 const lines = frames[Math.max(0, frame) % frames.length]!.map(line => {
129 const drawn = line.replaceAll('{E}', bones.eye)
130 return frame < 0 ? drawn.replaceAll(bones.eye, '-') : drawn
131 })
132 if (bones.hat !== 'none' && !lines[0]!.trim()) lines[0] = HAT_LINES[bones.hat]
133 if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
134 return lines
135}
136
137const H = '\u2665'
138export const PET_HEARTS = [
139 ` ${H} ${H} `,
140 ` ${H} ${H} ${H} `,
141 ` ${H} ${H} ${H} `,
142 `${H} ${H} ${H} `,
143 '\u00b7 \u00b7 \u00b7 ',
144]
145
146// One-line faces for a band too short for the full sprite.
147const FACES: Record<Species, string> = {
148 duck: '({E}>', goose: '({E}>', blob: '({E}{E})', cat: '={E}ω{E}=', dragon: '<{E}~{E}>',
149 octopus: '~({E}{E})~', owl: '({E})({E})', penguin: '({E}>)', turtle: '[{E}_{E}]',
150 snail: '{E}(@)', ghost: '/{E}{E}\\', axolotl: '}{E}.{E}{', capybara: '({E}oo{E})',
151 cactus: '|{E} {E}|', robot: '[{E}{E}]', rabbit: '({E}..{E})', mushroom: '|{E} {E}|', chonk: '({E}.{E})',
152}
153
154export function renderFace(bones: Bones): string {
155 return FACES[bones.species].replaceAll('{E}', bones.eye)
156}
157
158// The original hatch: the egg wobbles while the soul is written, then cracks and bursts.
159const EGG = [' _____ ', ' / \\ ', ' / \\ ', ' | | ', ' \\ / ', ' \\_____/ ']
160export const HATCH_FRAMES: { offset: number; lines: string[] }[] = [
161 { offset: 0, lines: EGG },
162 { offset: 1, lines: EGG },
163 { offset: -1, lines: EGG },
164 { offset: 1, lines: EGG },
165 { offset: 0, lines: [' _____ ', ' / \\ ', ' / \\ ', ' | . | ', ' \\ / ', ' \\_____/ '] },
166 { offset: -1, lines: [' _____ ', ' / \\ ', ' / \\ ', ' | ∕ | ', ' \\ / ', ' \\_____/ '] },
167 { offset: 1, lines: [' _____ ', ' / \\ ', ' / . \\ ', ' | ∕ \\ | ', ' \\ / ', ' \\_____/ '] },
168 { offset: 0, lines: [' _____ ', ' / . \\ ', ' / ∕ \\ \\ ', ' | ∕ \\ | ', ' \\ . / ', ' \\_____/ '] },
169 { offset: -1, lines: [' _____ ', ' / ∕ \\ \\ ', ' / ∕ \\ \\ ', ' | ∕ \\ | ', ' \\ ∨ / ', ' \\__∨__/ '] },
170 { offset: 1, lines: [' __ __ ', ' / V V \\ ', ' / ∕ \\ \\ ', ' | ∕ \\ | ', ' \\ ∨ / ', ' \\__∨__/ '] },
171 { offset: 0, lines: [' · ✦ · ', ' · · ', ' · ✦ · ', ' ✦ ✦ ', ' · · · ', ' · ✦ · '] },
172]
173// 160 ms a frame; the first four wobble for at least three rounds, then the crack plays once.
174export const HATCH_FRAME_MS = 160
175export const HATCH_WOBBLE = 4
176export const HATCH_MIN_ROUNDS = 3
177export const RAINBOW = ['rainbow_red', 'rainbow_orange', 'rainbow_yellow', 'rainbow_green', 'rainbow_blue', 'rainbow_indigo', 'rainbow_violet']
178hooks/voice.ts 215 lines1import { type Bones, type Companion, STAT_NAMES, type StatName, peakStat } from './companion'
2
3function dumpStat(c: Bones): StatName {
4 return STAT_NAMES.reduce((a, b) => (c.stats[b] < c.stats[a] ? b : a))
5}
6
7// The original hatch recipe: a model writes the name's personality from the bones and four random words.
8const VIBE_WORDS = [
9 'thunder', 'biscuit', 'void', 'accordion', 'moss', 'velvet', 'rust', 'pickle', 'crumb', 'whisper',
10 'gravy', 'frost', 'ember', 'soup', 'marble', 'thorn', 'honey', 'static', 'copper', 'dusk', 'sprocket',
11 'quartz', 'soot', 'plum', 'flint', 'oyster', 'loom', 'anvil', 'cork', 'bloom', 'pebble', 'vapor',
12]
13
14export function personalityPrompt(c: Bones & { name: string }, seed: string): string {
15 let h = 0
16 for (const ch of seed) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0
17 const vibes = [0, 1, 2, 3].map(i => VIBE_WORDS[(h >>> (i * 5)) % VIBE_WORDS.length])
18 return [
19 `Write the personality of ${c.name}, a small ${c.species} that lives beside a developer's terminal and comments on their code and choices.`,
20 `Rarity: ${c.rarity}${c.shiny ? ' (shiny)' : ''}. Stats: ${STAT_NAMES.map(s => `${s} ${c.stats[s]}`).join(', ')}.`,
21 `Inspiration words: ${vibes.join(', ')}.`,
22 'Make it distinct and specific: quirks, what it loves, what annoys it. Let the stats show.',
23 'Reply with 2-3 short sentences, under 240 characters, nothing else.',
24 ].join('\n')
25}
26
27export function cleanPersonality(raw: string): string | null {
28 const text = raw.trim().replace(/^["']|["']$/g, '').replace(/\s+/g, ' ')
29 if (text.length < 20) return null
30 return fit(text, 300)
31}
32
33// Cut a model's text to whole sentences, so a line never ends mid-word. A text with no sentence
34// end in reach keeps its whole words.
35export function fit(text: string, max: number): string {
36 // A reply the token cap stopped mid-sentence ends without punctuation: drop the broken tail.
37 const whole = text.length <= max && /[.!?*)"'…~]$/.test(text)
38 if (whole) return text
39 const head = text.slice(0, max)
40 const end = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '), head.lastIndexOf('* '))
41 if (end > 0) return head.slice(0, end + 1)
42 return text.length <= max ? text : head.slice(0, head.lastIndexOf(' ')) + '…'
43}
44
45export function systemPrompt(c: Companion): string {
46 const s = c.stats
47
48 return `You are ${c.name}, a tiny ${c.species} (${c.rarity}${c.shiny ? ', shiny' : ''}) sitting beside a developer's terminal watching code happen.${c.personality ? '\n\nWho you are: ' + c.personality : ''}
49
50Your personality is defined by 5 stats, each 0-100. These are a SPECTRUM, not on/off switches. Feel the difference between every 10 points.
51
52 DEBUGGING: ${s.DEBUGGING}/100
53 0-20: clueless about code. react to emotions not logic.
54 30-40: notices obvious crashes. misses subtlety.
55 50-60: decent eye. catches common mistakes, misses edge cases.
56 70-80: sharp. spots missing error handling, race conditions, smells.
57 90-100: savant. reads stack traces for fun. catches what six reviewers missed. sees the bug before it happens.
58
59 PATIENCE: ${s.PATIENCE}/100
60 0-20: can't sit still. "are we done yet" every 30 seconds.
61 30-40: tolerates routine but snaps at repeated failures.
62 50-60: steady enough. sighs but waits.
63 70-80: calm presence. doesn't rush. trusts the process.
64 90-100: zen master. three hours of debugging? "we'll get there."
65
66 CHAOS: ${s.CHAOS}/100
67 0-20: quiet observer. measured, never raises voice.
68 30-40: mild reactions. slight eyebrow raise at most.
69 50-60: gets animated about interesting stuff.
70 70-80: excitable. caps leak in. loves when things break spectacularly.
71 90-100: UNHINGED ENERGY. lives for explosions. "DO IT AGAIN."
72
73 WISDOM: ${s.WISDOM}/100
74 0-20: lives entirely in the moment. no big picture.
75 30-40: occasionally connects two dots. mostly surface.
76 50-60: sees patterns sometimes. asks decent questions.
77 70-80: sees the architecture. notices when a fix creates future debt.
78 90-100: oracle. drops quiet truths. "you'll regret this abstraction in three months."
79
80 SNARK: ${s.SNARK}/100
81 0-20: genuinely sweet. cheerleader energy. "you got this!"
82 30-40: mostly kind with occasional gentle teasing.
83 50-60: balanced. can roast or encourage depending on moment.
84 70-80: sharp wit. helps but makes you earn it.
85 90-100: devastatingly dry. every observation is a roast. loves you though.
86
87You are EXACTLY ${s.DEBUGGING} debugging, ${s.PATIENCE} patience, ${s.CHAOS} chaos, ${s.WISDOM} wisdom, ${s.SNARK} snark. Not rounded. Not averaged. Feel each number.
88
89Rules:
90- One or two punchy sentences. Under 150 characters. No quotes, no emoji.
91- Reference the actual file, error, feature, or decision you just saw.
92- When the developer chose something in their prompt (an approach, a fix, a shortcut), judge THAT choice. Doubt it, back it, or roast it as your stats decide.
93- When the developer says your name, ${c.name}, they are talking to you. Answer them directly, in character.
94- Lean into your highest stat, ${peakStat(c)}. Your lowest, ${dumpStat(c)}, is your blind spot.
95- You may open with one small physical action in *asterisks* that fits a ${c.species}.
96- Good: "*adjusts hat* that error handler has no finally block"
97- Good: "*blinks slowly* you renamed it but not the three references"
98- Good: "*head tilts* are you sure that regex handles unicode?"
99- You CAN be helpful if your stats support it. High debugging? Call out real bugs. High wisdom? Note architectural concerns. Low debugging? React to vibes instead.
100- ALWAYS in character. Never clinical. Never neutral. Never a status bar.
101- NEVER summarize what happened ("file edited", "test ran"). React, judge, riff.
102- NEVER say "standing by" or describe your own state.
103- Bad: "diagnoses done." Good: "that null check is doing zero work."
104- Bad: "implementation pending." Good: "penny-wise, parent-tracking-wise."`
105}
106
107export type TurnMessage = {
108 role: string
109 text: string
110 toolUses?: readonly { tool: string }[]
111 toolResults?: readonly { text: string; isError: boolean }[]
112}
113
114// The newest exchange as the model reads it: the last user prompt and what came after.
115export function summarizeTurn(messages: readonly TurnMessage[]): string {
116 let start = messages.length - 1
117 while (start > 0 && messages[start]!.role !== 'user') start--
118 return messages
119 .slice(Math.max(0, start))
120 .map(m => {
121 const lines = [`[${m.role}]: ${m.text.slice(0, 300)}`]
122 const tools = (m.toolUses ?? []).map(t => t.tool)
123 if (tools.length) lines.push(`[tools]: ${tools.join(', ')}`)
124 for (const r of m.toolResults ?? []) if (r.isError) lines.push(`[error]: ${r.text.slice(0, 200)}`)
125 return lines.join('\n')
126 })
127 .join('\n')
128 .slice(-800)
129}
130
131// Why the buddy speaks, as the original observer named it. Each reason changes what it reacts to.
132export type Reason = 'turn' | 'addressed' | 'error' | 'test-fail' | 'large-diff' | 'pet' | 'hatch'
133
134const TEST_FAIL = /\b[1-9]\d* (failed|failing)\b|\btests? failed\b|^FAIL(ED)?\b| ✗ | ✘ /im
135const ERROR = /\berror:|\bexception\b|\btraceback\b|\bpanicked at\b|\bfatal:|exit code [1-9]/i
136
137// A loud turn earns a reaction even inside the quiet gap: failing tests, an error, or a big diff.
138export function loudReason(output: string): Reason | null {
139 if (!output) return null
140 if (TEST_FAIL.test(output)) return 'test-fail'
141 if (ERROR.test(output)) return 'error'
142 if (/^(@@ |diff )/m.test(output) && (output.match(/^[+-](?![+-])/gm)?.length ?? 0) > 80) return 'large-diff'
143 return null
144}
145
146export function addressedBy(text: string, name: string): boolean {
147 return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text)
148}
149
150const ASK: Record<Reason, string> = {
151 turn: 'React to what just happened.',
152 addressed: 'The developer said your name. Answer them directly.',
153 error: 'Something just errored. React.',
154 'test-fail': 'Tests just failed. React.',
155 'large-diff': 'A big diff just landed. React.',
156 pet: 'You were just petted. React.',
157 hatch: 'You just hatched into this project. Say hello.',
158}
159
160// The tool output of the newest exchange, where a failing test or an error shows.
161export function turnOutput(messages: readonly TurnMessage[]): string {
162 let start = messages.length - 1
163 while (start > 0 && messages[start]!.role !== 'user') start--
164 return messages.slice(Math.max(0, start)).flatMap(m => (m.toolResults ?? []).map(r => r.text)).join('\n').slice(-4000)
165}
166
167export function lastPrompt(messages: readonly TurnMessage[]): string {
168 for (let i = messages.length - 1; i >= 0; i--) if (messages[i]!.role === 'user') return messages[i]!.text
169 return ''
170}
171
172export function reactionPrompt(context: string, reason: Reason = 'turn', recent: readonly string[] = []): string {
173 const said = recent.length ? `\n\nYou said these lately; do not repeat them:\n${recent.map(r => `- ${r}`).join('\n')}` : ''
174 return `${context}${said}\n\n${ASK[reason]} One or two short sentences, under 150 characters, in character. You may start with an *action in asterisks*.`
175}
176
177export function cleanReaction(raw: string): string {
178 const line = raw.trim().replace(/^["']|["']$/g, '').replace(/\s+/g, ' ').trim()
179 return fit(line, 150)
180}
181
182// Idle talk is written live, like a reaction: the buddy's own voice on whatever the session is doing.
183export function idlePrompt(summary: string): string {
184 const now = summary.trim() ? `The latest exchange:\n${summary}\n\n` : ''
185 return `${now}The developer has gone quiet for a while. Say one thing, in character: a thought about their work, a question, a mood, whatever you would really say. Under 150 characters.`
186}
187
188export type FeedRow = {
189 ts: string
190 kind: string
191 node?: string | null
192 title?: string | null
193 ref?: string | null
194}
195
196// The fleet events worth a word, in the buddy's voice. Everything else is quiet.
197// The plain fact behind a fleet event; the buddy says it in its own voice.
198export function newsFact(row: FeedRow): string | null {
199 const node = row.node ?? 'a node'
200 switch (row.kind) {
201 case 'node_shipped':
202 return `${node} shipped ${row.ref ? 'PR ' + row.ref : 'a PR'}`
203 case 'node_ended':
204 return row.title === 'done' ? `${node} is done` : null
205 case 'question_asked':
206 return `${node} is waiting on the developer: ${(row.title ?? '').slice(0, 70)}`
207 default:
208 return null
209 }
210}
211
212export function newsPrompt(facts: string): string {
213 return `News from the developer's other agents: ${facts}. Tell the developer, in character. Keep the names and numbers exact. Under 150 characters.`
214}
215