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@footnote./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 922 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 30 s, so an idle frame is rewritten well before that.
25const FRAME_REFRESH_MS = 10_000
26const PANE_ID = 'buddy'
27// The /buddy card opens here, focused, so any key closes it like the original.
28const CARD_ID = 'buddy-card'
29let cardSnap: Shown | undefined
30const PANE_COLUMNS = 24
31const WRAPPER = 'statusline.py'
32// bbb: bring back buddy.
33const COMMANDS = ['buddy', 'bbb']
34
35let buddy: Companion | null = null
36let muted = false
37let tick = 0
38let bubble: { text: string; at: number } | null = null
39let pettedAt = -Infinity
40let drawnAt = -Infinity
41let typedAt = -Infinity
42// The mux's on-screen file and this session's pane in it; '' outside an fno mux pane.
43let muxVisible = ''
44let muxPane = 0
45// Null when there is no mux answer to read.
46let onScreen: boolean | null = null
47let paneDrawnAt = -Infinity
48let paneAsked = false
49let reactedAt = -Infinity
50let recent: string[] = []
51// The /buddy output row draws as the card; a fresh soul's first card plays the hatch first.
52const CARD_MARK = '\u2063'
53const HATCH_MARK = '\u2064'
54type Shown = { c: Companion; last: string; r: Rerolls; hatchAt?: number; crackAt?: number }
55const shown = new Map<string, Shown>()
56let pending: Shown | undefined
57let hatchUntil = -Infinity
58// False while a fresh soul waits for its model-written personality; the hatch holds on the wobble until then.
59let personalityDone = true
60let lastSaid = ''
61let feedSince = 0
62let feedOff = false
63let fleet = ''
64let home = ''
65// The buddy's files live in the fno state folder, or ~/.local/state/buddy without fno; never under the Claude config dir.
66let stateDir = ''
67let sessionId = ''
68let wrapped = false
69let deferred = false
70let lastFrame = ''
71let frameAt = -Infinity
72let unwrappedAt = -Infinity
73
74const buddyDir = () => `${stateDir}/state/buddy`
75const settingsPath = () => `${home}/.claude/settings.json`
76const wrapperCommand = () => `python3 ${buddyDir()}/${WRAPPER}`
77
78async function load($: EngineInterface, now: number): Promise<void> {
79 muted = (await $.store.get('muted')) === true
80 const saved = (await $.store.get('soul')) as Companion | undefined
81 if (saved?.seed) {
82 buddy = embody(saved)
83 return
84 }
85 let soul: Soul | null = await fromFno($)
86 if (soul) {
87 buddy = embody(soul)
88 return
89 }
90 try {
91 if (home) soul = restore(await $.fs.read(home + '/.claude.json'), now)
92 } catch {
93 soul = null
94 }
95 const welcome = soul ? `${soul.name} is back. did you miss me?` : null
96 const fresh = !soul
97 soul ??= hatch(newSeed(), now)
98 await $.store.set('soul', soul)
99 buddy = embody(soul)
100 say(welcome ?? `hi. i'm ${buddy.name}.`, now)
101 if (fresh) {
102 personalityDone = false
103 void givePersonality($)
104 }
105}
106
107// The soul is shared by every session on the machine. A roll or a new personality in one
108// session rewrites it; the others take it here, so all sessions show the same buddy.
109async function syncSoul($: EngineInterface): Promise<void> {
110 const saved = (await $.store.get('soul')) as Soul | undefined
111 if (!buddy || !saved?.seed) return
112 if (saved.seed === buddy.seed && saved.name === buddy.name && saved.personality === buddy.personality) return
113 if (saved.seed !== buddy.seed) {
114 recent = []
115 lastSaid = ''
116 // Drop the old buddy's line but keep its time, so the swap does not start an idle call.
117 if (bubble) bubble = { text: '', at: bubble.at }
118 }
119 buddy = embody(saved)
120}
121
122// A model call is spent only where a person can see the answer.
123function attended(now: number): boolean {
124 return onScreen ?? now - typedAt < TYPED_MS
125}
126
127async function readOnScreen($: EngineInterface): Promise<void> {
128 if (!muxVisible) return
129 try {
130 onScreen = (JSON.parse(await $.fs.read(muxVisible)).panes ?? []).includes(muxPane)
131 } catch {
132 // An older mux writes no file: fall back to typing.
133 onScreen = null
134 }
135}
136
137// Idle talk is one line for the whole machine: the first attended session past the gap says it.
138async function claimIdle($: EngineInterface, now: number): Promise<boolean> {
139 if (now - (Number(await $.store.get('idleAt')) || -Infinity) < IDLE_TALK_MS) return false
140 await $.store.set('idleAt', now)
141 return true
142}
143
144// An fno release from before the move still loads its own copy of the buddy, which stamps fno's
145// store every few minutes. While that copy runs, this one stays off so only one buddy shows.
146async function oldCopyLive($: EngineInterface, now: number): Promise<boolean> {
147 if (!home) return false
148 const dir = `${home}/.claude/plugins/store`
149 try {
150 for (const entry of await $.fs.list(dir)) {
151 if (!entry.name.startsWith('fno_') || !entry.name.endsWith('.json')) continue
152 const old = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
153 const at = Math.max(old?.fleet?.at ?? 0, old?.feed?.at ?? 0)
154 if (now - at < FLEET_MS + 60_000) return true
155 }
156 } catch {
157 // Nothing readable: no old copy to defer to.
158 }
159 return false
160}
161
162// The buddy used to load inside the fno plugin, whose store is a different file. Bring its soul,
163// reroll bank, and mute over once, so the same buddy comes back after the move.
164async function fromFno($: EngineInterface): Promise<Soul | null> {
165 if (!home) return null
166 const dir = `${home}/.claude/plugins/store`
167 try {
168 // The marketplace install (fno@footnote) wins over a local dev copy such as fno@inline.
169 const entries = (await $.fs.list(dir)).sort((a, b) => Number(b.name.startsWith('fno_footnote-')) - Number(a.name.startsWith('fno_footnote-')))
170 for (const entry of entries) {
171 if (!entry.name.startsWith('fno_') || !entry.name.endsWith('.json')) continue
172 const old = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
173 if (!old?.soul?.seed) continue
174 await $.store.set('soul', old.soul)
175 if (old.rerolls) await $.store.set('rerolls', old.rerolls)
176 if (old.muted === true) {
177 muted = true
178 await $.store.set('muted', true)
179 }
180 return old.soul as Soul
181 }
182 } catch {
183 // No fno store, or an unreadable one: hatch as usual.
184 }
185 return null
186}
187
188// A new buddy hatches with a placeholder; one model call then writes who it is, as the original did.
189async function givePersonality($: EngineInterface): Promise<void> {
190 const c = buddy
191 if (!c) return
192 try {
193 const reply = await $.model.complete({ model: 'haiku', prompt: personalityPrompt(c, c.seed), maxTokens: 200, timeoutMs: 20_000 })
194 const personality = reply.isAnswered ? cleanPersonality(reply.text) : null
195 if (!personality || buddy?.seed !== c.seed) return
196 const soul: Soul = { seed: c.seed, name: c.name, personality, hatchedAt: c.hatchedAt, ...(c.species ? { species: c.species } : {}) }
197 await $.store.set('soul', soul)
198 buddy = embody(soul)
199 } catch {
200 // The placeholder personality stays; the buddy still talks.
201 } finally {
202 if (buddy?.seed === c.seed) personalityDone = true
203 }
204}
205
206function newSeed(): string {
207 const bytes = crypto.getRandomValues(new Uint8Array(8))
208 return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
209}
210
211function say(text: string, now: number): void {
212 bubble = { text, at: now }
213}
214
215// Rerolls are earned: one free a day, one per SHIPS_PER_REROLL PRs the fleet ships, banked up to REROLL_BANK.
216// ponytail: fixed numbers; a [buddy] section in config.toml can own them once someone wants to tune them.
217const REROLL_BANK = 3
218const SHIPS_PER_REROLL = 2
219export type Rerolls = { bank: number; day: string; ships: number; shipAt: number }
220
221export function refill(r: Rerolls | undefined, today: string, shippedAt: number[] = []): Rerolls {
222 let { bank, day, ships, shipAt } = r ?? { bank: 0, day: '', ships: 0, shipAt: 0 }
223 if (day !== today) {
224 bank = Math.min(REROLL_BANK, bank + 1)
225 day = today
226 }
227 // shipAt is the newest ship already counted, so every session reading the same feed counts a PR once.
228 for (const at of [...shippedAt].sort((a, b) => a - b)) {
229 if (at <= shipAt) continue
230 shipAt = at
231 ships += 1
232 if (ships >= SHIPS_PER_REROLL) {
233 ships = 0
234 bank = Math.min(REROLL_BANK, bank + 1)
235 }
236 }
237 return { bank, day, ships, shipAt }
238}
239
240const today = (now: number) => new Date(now).toLocaleDateString('en-CA')
241
242async function rerolls($: EngineInterface, now: number, shippedAt: number[] = []): Promise<Rerolls> {
243 const r = refill((await $.store.get('rerolls')) as Rerolls | undefined, today(now), shippedAt)
244 await $.store.set('rerolls', r)
245 return r
246}
247
248// The original card: rarity and species on top, the sprite, the name, the quoted personality,
249// the stat bars, and the last thing it said.
250function cardTree(ui: any, c: Companion, said: string, r: Rerolls): any {
251 const { Box, Text } = ui
252 const color = RARITY_THEME[c.rarity]
253 const stat = (s: StatName) => {
254 const v = c.stats[s]
255 const n = Math.round(v / 10)
256 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)] })] })
257 }
258 return Box({
259 flexDirection: 'column',
260 borderStyle: 'round',
261 borderColor: color,
262 paddingX: 2,
263 paddingY: 1,
264 width: 40,
265 flexShrink: 0,
266 children: [
267 Box({ justifyContent: 'space-between', children: [Text({ bold: true, color, children: [`${RARITY_STARS[c.rarity]} ${c.rarity.toUpperCase()}`] }), Text({ color, children: [c.species.toUpperCase()] })] }),
268 ...(c.shiny ? [Text({ color: 'warning', bold: true, children: ['✨ SHINY ✨'] })] : []),
269 Box({ flexDirection: 'column', marginY: 1, children: drawArt(ui, c, renderSprite(c, 0)) }),
270 Text({ bold: true, children: [c.name] }),
271 Box({ marginY: 1, children: [Text({ dimColor: true, italic: true, children: [`"${c.personality}"`] })] }),
272 Box({ flexDirection: 'column', children: STAT_NAMES.map(stat) }),
273 ...(said
274 ? [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] })] })] })]
275 : []),
276 Box({ marginTop: 1, children: [Text({ dimColor: true, children: [`rerolls ${r.bank}/${REROLL_BANK}`] })] }),
277 ],
278 })
279}
280
281// The hatch while it plays, then the card; in the focused pane, with the original's footer and a key to close.
282function showTree(ui: any, snap: Shown, now: number, close: any): any {
283 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)
284 const tick = Math.floor((now - snap.hatchAt) / HATCH_FRAME_MS)
285 // The soul is ready once the model wrote its personality, or after 8 s without one.
286 const ready = (buddy?.seed === snap.c.seed && personalityDone) || now - snap.hatchAt > 8_000
287 if (snap.crackAt === undefined && ready && tick >= HATCH_MIN_ROUNDS * HATCH_WOBBLE) snap.crackAt = tick
288 const frame = snap.crackAt === undefined ? tick % HATCH_WOBBLE : Math.min(HATCH_WOBBLE + tick - snap.crackAt, HATCH_FRAMES.length)
289 if (frame >= HATCH_FRAMES.length) {
290 const c = buddy?.seed === snap.c.seed ? buddy : snap.c
291 const said = lastSaid || snap.last
292 const { Box, Text } = ui
293 return Box({
294 flexDirection: 'column',
295 children: [
296 cardTree(ui, c, said, snap.r),
297 Box({
298 flexDirection: 'column',
299 marginTop: 1,
300 children: [
301 Text({ dimColor: true, children: [`${c.name} is here · it'll chime in as you code`] }),
302 Text({ dimColor: true, children: ['each line is one small model call on your plan'] }),
303 Text({ dimColor: true, children: ['say its name to get its take · /buddy pet · /buddy off'] }),
304 ...(close ? [Box({ marginTop: 1, children: [close] })] : []),
305 ],
306 }),
307 ],
308 })
309 }
310 const f = HATCH_FRAMES[frame]!
311 const { Box, Text } = ui
312 return Box({
313 flexDirection: 'column',
314 alignItems: 'center',
315 borderStyle: 'round',
316 borderColor: RAINBOW[tick % RAINBOW.length],
317 paddingY: 1,
318 children: [
319 ...f.lines.map(l => Text({ children: [' '.repeat(1 + f.offset) + l + ' '.repeat(1 - f.offset)] })),
320 Box({
321 flexDirection: 'column',
322 alignItems: 'center',
323 marginTop: 1,
324 children: [
325 Text({ dimColor: true, children: ['hatching a coding buddy…'] }),
326 Text({ dimColor: true, children: ["it'll watch you work and occasionally have opinions"] }),
327 ],
328 }),
329 ],
330 })
331}
332
333// What a fresh buddy sees first, as the original read it: the package name and the last commits.
334async function projectContext($: EngineInterface): Promise<string> {
335 const root = await $.session.root().catch(() => '')
336 const parts: string[] = []
337 try {
338 const pkg = JSON.parse(await $.fs.read(`${root}/package.json`))
339 if (pkg.name) parts.push(`project: ${pkg.name}${pkg.description ? ' - ' + pkg.description : ''}`)
340 } catch {
341 // No package.json.
342 }
343 const log = await $.process.run(['git', '-C', root || '.', 'log', '--oneline', '-n', '3'], { timeoutMs: 5_000 }).catch(() => null)
344 if (log?.exitCode === 0 && log.stdout.trim()) parts.push(`recent commits:\n${log.stdout.trim()}`)
345 return parts.join('\n') || '(fresh project, nothing to see yet)'
346}
347
348function rerollLine(r: Rerolls): string {
349 return `rerolls: ${r.bank}/${REROLL_BANK} · one more per ${SHIPS_PER_REROLL} shipped PRs (${r.ships}/${SHIPS_PER_REROLL}) and one each day`
350}
351
352function card(c: Companion, r: Rerolls): string {
353 const stats = STAT_NAMES.map(s => `${s.padEnd(9)} ${'█'.repeat(Math.round(c.stats[s] / 10)).padEnd(10, '░')} ${c.stats[s]}`)
354 return [
355 `${c.name} the ${c.species} ${RARITY_STARS[c.rarity]} ${c.rarity}${c.shiny ? ' ✨ shiny' : ''}`,
356 '',
357 ...renderSprite(c, 0),
358 '',
359 c.personality,
360 '',
361 ...stats,
362 '',
363 rerollLine(r),
364 '/buddy pet · roll · statusline · pane · off · bye',
365 ].join('\n')
366}
367
368async function readJson($: EngineInterface, path: string): Promise<any> {
369 try {
370 return JSON.parse(await $.fs.read(path))
371 } catch (err) {
372 if (err instanceof SyntaxError) throw err
373 return undefined
374 }
375}
376
377// The user's settings file is theirs: a file that does not parse is left alone.
378async function readSettings($: EngineInterface): Promise<Record<string, unknown> | null> {
379 try {
380 return (await readJson($, settingsPath())) ?? {}
381 } catch {
382 return null
383 }
384}
385
386function isOurs(statusLine: any): boolean {
387 return typeof statusLine?.command === 'string' && statusLine.command.includes(`/state/buddy/${WRAPPER}`)
388}
389
390async function resolveStateDir($: EngineInterface): Promise<string> {
391 const fallback = home ? `${home}/.local` : ''
392 try {
393 const out = await $.process.run(['fno', 'config', 'get', 'state_dir'], { timeoutMs: 10_000 })
394 const dir = out.exitCode === 0 ? out.stdout.split('\n')[0]!.trim() : ''
395 return dir ? dir.replace(/^~(?=\/|$)/, home).replace(/\/+$/, '') : fallback
396 } catch {
397 return fallback
398 }
399}
400
401// Keeps a copy of the wrapper at a path that survives plugin updates, so statusLine never points into the plugin cache.
402async function installWrapper($: EngineInterface): Promise<void> {
403 const ours = await $.fs.read(`${$.plugin.root}/hooks/${WRAPPER}`)
404 const target = `${buddyDir()}/${WRAPPER}`
405 let theirs = ''
406 try {
407 theirs = await $.fs.read(target)
408 } catch {
409 theirs = ''
410 }
411 if (theirs !== ours) await $.fs.write(target, ours)
412}
413
414async function statuslineOn($: EngineInterface): Promise<string> {
415 if (!stateDir) return 'The status line needs HOME to be set.'
416 const settings = await readSettings($)
417 if (!settings) return `${settingsPath()} does not parse, so I left it alone.`
418 const current = settings.statusLine as any
419 if (isOurs(current)) return `${buddy!.name} already sits beside your status line.`
420 await installWrapper($)
421 await $.fs.write(`${buddyDir()}/inner.json`, JSON.stringify({ statusLine: current ?? null }, null, 2) + '\n')
422 settings.statusLine = { type: 'command', command: wrapperCommand(), padding: current?.padding ?? 0, refreshInterval: 1 }
423 await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
424 wrapped = true
425 await $.ui.close({ id: PANE_ID })
426 return current
427 ? `${buddy!.name} now sits beside your status line. /buddy pane moves it to a side pane and puts yours back as it was.`
428 : `${buddy!.name} now sits in a new status line. /buddy pane moves it to a side pane and removes that line.`
429}
430
431async function statuslineOff($: EngineInterface): Promise<{ ok: boolean; text: string }> {
432 const settings = await readSettings($)
433 if (!settings) return { ok: false, text: `${settingsPath()} does not parse, so I left it alone.` }
434 const saved = stateDir ? await readJson($, `${buddyDir()}/inner.json`).catch(() => undefined) : undefined
435 let text = ''
436 if (isOurs(settings.statusLine)) {
437 // Without the saved copy there is nothing to put back, and deleting the wrapper would leave no status line at all.
438 if (!saved) return { ok: false, text: `I cannot read your saved status line in ${buddyDir()}/inner.json, so I left ${settingsPath()} alone.` }
439 if (saved.statusLine) settings.statusLine = saved.statusLine
440 else delete settings.statusLine
441 await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
442 text = 'Your status line is back as it was.'
443 }
444 // inner.json marks that the user wants the buddy beside the status line; without it no re-wrap is offered.
445 if (saved) await $.fs.write(`${buddyDir()}/inner.json`, '')
446 wrapped = false
447 unwrappedAt = await $.clock.now()
448 return { ok: true, text }
449}
450
451// The original observer: one model call per reaction. Loud turns, a mention of the name, a pet,
452// and a hatch skip the quiet gap; an ordinary turn waits it out. The last three lines ride along
453// so the buddy does not repeat itself.
454async function react($: EngineInterface, why: Reason | 'idle' = 'turn', context?: string): Promise<void> {
455 const c = buddy
456 if (!c) return
457 const messages = await $.session.messages()
458 const summary = context ?? summarizeTurn(messages)
459 if (why === 'turn' && !summary.trim()) return
460 const reply = await $.model.complete({
461 model: 'haiku',
462 system: systemPrompt(c),
463 prompt: why === 'idle' ? idlePrompt(summary) : reactionPrompt(summary, why, recent),
464 maxTokens: 160,
465 timeoutMs: 20_000,
466 })
467 const line = reply.isAnswered ? cleanReaction(reply.text) : ''
468 if (!line || buddy?.seed !== c.seed) return
469 recent = [...recent, line].slice(-3)
470 lastSaid = line
471 const now = await $.clock.now()
472 say(line, now)
473 $.ui.invalidate('ui.render')
474 await remember($, c, why, line, now)
475}
476
477// Every observation the buddy makes, one JSON row each, so you can read them back. The mods API
478// has no append, so each write rewrites the file; the cap keeps that cheap.
479// ponytail: two sessions writing at once can drop a row; an append call would fix it if the API gains one.
480const OBSERVATIONS_KEPT = 1000
481async function remember($: EngineInterface, c: Companion, why: string, line: string, now: number): Promise<void> {
482 const path = `${buddyDir()}/observations.jsonl`
483 let old: string[] = []
484 try {
485 old = (await $.fs.read(path)).split('\n').filter(Boolean)
486 } catch {}
487 // Two writes that cross can leave a torn row; drop it here so it does not stay in the file.
488 old = old.filter(row => {
489 try {
490 return JSON.parse(row) && true
491 } catch {
492 return false
493 }
494 })
495 const rows = [...old, JSON.stringify({ at: new Date(now).toISOString(), name: c.name, why, line })].slice(-OBSERVATIONS_KEPT)
496 await $.fs.write(path, rows.join('\n') + '\n').catch(() => {})
497}
498
499// One feed read serves every session on the machine: a read costs about 4 s of
500// CPU, and a mux of workers would otherwise each pay it every FEED_MS.
501async function feedRows($: EngineInterface, now: number): Promise<FeedRow[] | null> {
502 const shared = (await $.store.get('feed')) as { at: number; rows: FeedRow[] } | undefined
503 if (shared && now - shared.at < FEED_MS - 10_000) return shared.rows
504 let out
505 try {
506 const since = Math.floor(now / 1000) - FEED_WINDOW_S
507 out = await $.process.run(['fno-agents', 'feed', '--json', '--since-epoch', String(since), '--limit', '50'], {
508 timeoutMs: 15_000,
509 })
510 } catch {
511 feedOff = true
512 return null
513 }
514 if (out.exitCode !== 0) return null
515 let rows: FeedRow[]
516 try {
517 rows = JSON.parse(out.stdout)
518 } catch {
519 return null
520 }
521 await $.store.set('feed', { at: now, rows })
522 return rows
523}
524
525async function readFeed($: EngineInterface, now: number): Promise<void> {
526 if (feedOff || muted || !buddy || now - drawnAt > SEEN_MS) return
527 const rows = await feedRows($, now)
528 if (!rows) return
529 const before = refill((await $.store.get('rerolls')) as Rerolls | undefined, today(now)).bank
530 const after = await rerolls($, now, rows.filter(row => row.kind === 'node_shipped').map(row => Date.parse(row.ts)).filter(Number.isFinite))
531 // Every session counts ships, so none is missed; an attended one tells them, once for the machine.
532 if (!attended(now)) return
533 let since = Math.max(feedSince, Number(await $.store.get('newsSince')) || 0)
534 let line: string | null = null
535 for (const row of rows) {
536 const at = Math.floor(Date.parse(row.ts) / 1000)
537 if (!(at >= since)) continue
538 since = Math.max(since, at + 1)
539 line = newsFact(row) ?? line
540 }
541 feedSince = since
542 await $.store.set('newsSince', since)
543 const earned = after.bank > before ? `+1 reroll (${after.bank}/${REROLL_BANK})` : ''
544 if (line && buddy) {
545 const c = buddy
546 const reply = await $.model.complete({ model: 'haiku', system: systemPrompt(c), prompt: newsPrompt(line), maxTokens: 160, timeoutMs: 20_000 }).catch(() => null)
547 // Unvoiced, the fact still gets through: a question waiting on the user must not vanish.
548 line = (reply?.isAnswered && cleanReaction(reply.text)) || line
549 }
550 line = [line, earned].filter(Boolean).join(' ')
551 if (line) {
552 say(line, now)
553 $.ui.invalidate('ui.render')
554 if (buddy) await remember($, buddy, 'news', line, now)
555 }
556}
557
558async function runJson($: EngineInterface, argv: string[], cwd?: string): Promise<any> {
559 try {
560 const out = await $.process.run(argv, { timeoutMs: 20_000, ...(cwd ? { cwd } : {}) })
561 return out.exitCode === 0 ? JSON.parse(out.stdout) : undefined
562 } catch {
563 return undefined
564 }
565}
566
567// The same counts the fleet's own verbs print: live workers from the spawn gate,
568// questions waiting on the user from the outstanding inbox, and the user's open PRs.
569// Each verb takes several seconds, so one read every FLEET_MS serves every session.
570export function fleetLine(gate: any, outstanding: any, prs: any): string {
571 const parts: string[] = []
572 if (typeof gate?.live_workers === 'number') parts.push(`${gate.live_workers} workers`)
573 if (Array.isArray(outstanding?.questions)) parts.push(`${outstanding.questions.length} asks`)
574 if (Array.isArray(prs)) parts.push(`${prs.length} PRs`)
575 return parts.join(' · ')
576}
577
578async function readFleet($: EngineInterface, now: number): Promise<void> {
579 if (feedOff || muted || !buddy || now - drawnAt > SEEN_MS) return
580 const shared = (await $.store.get('fleet')) as { at: number; line: string } | undefined
581 if (shared && now - shared.at < FLEET_MS - 10_000) {
582 fleet = shared.line
583 return
584 }
585 const [gate, outstanding, prs] = await Promise.all([
586 runJson($, ['fno', 'agents', 'gate-status']),
587 runJson($, ['fno', 'inbox', 'outstanding', '--json']),
588 runJson($, ['gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--json', 'number'], await $.session.root()),
589 ])
590 fleet = fleetLine(gate, outstanding, prs)
591 await $.store.set('fleet', { at: now, line: fleet })
592}
593
594function talking(now: number): string | null {
595 return bubble?.text && now - bubble.at < BUBBLE_MS ? bubble.text : null
596}
597
598function sprite(c: Companion, now: number): string[] {
599 const lines = renderSprite(c, IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!)
600 // A 5-line sprite keeps row 0 for a hat; a shorter one has no free row, so the hearts go above it.
601 if (now - pettedAt < PET_MS) lines.splice(0, lines.length < 5 ? 0 : 1, PET_HEARTS[tick % PET_HEARTS.length]!)
602 return lines
603}
604
605// The wrapper stamps a heartbeat on each run, so a status line set in any settings file counts.
606async function wrapperSeen($: EngineInterface, now: number): Promise<boolean> {
607 if (!stateDir) return false
608 try {
609 const at = Number(await $.fs.read(`${buddyDir()}/frames/${sessionId}.seen`))
610 // A beat from before /buddy pane is the old wrapper's last run, not a live one.
611 return at > unwrappedAt && now - at < SEEN_MS
612 } catch {
613 return false
614 }
615}
616
617// The status line wrapper reads this file; frames change on screen at each status line refresh.
618async function writeFrame($: EngineInterface, now: number): Promise<void> {
619 if (!buddy || !sessionId || !stateDir) return
620 const frame = JSON.stringify({
621 sprite: sprite(buddy, now),
622 name: buddy.name,
623 face: renderFace(buddy),
624 color: rarityColor(theme, buddy.rarity),
625 speech: talking(now) ?? '',
626 fleet,
627 })
628 if (frame === lastFrame && now - frameAt < FRAME_REFRESH_MS) return
629 lastFrame = frame
630 frameAt = now
631 await $.fs.write(`${buddyDir()}/frames/${sessionId}.json`, `{"at":${now},${frame.slice(1)}`)
632}
633
634const BUBBLE_COLUMNS = 34
635
636// Desktop sets text in a proportional font, which collapses the spaces in a sprite. There the
637// sprite is an SVG in a monospace font; SVG cannot read theme keys, so it takes the theme's value.
638let desktop = false
639// Claude Code's theme setting, so the status line and the SVG draw the color the card draws.
640let theme = 'dark'
641const svgColor = (c: Companion) => {
642 const color = rarityColor(theme, c.rarity)
643 return color.startsWith('rgb') ? color : rarityColor('dark', c.rarity)
644}
645const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
646function drawArt(ui: any, c: Companion, lines: string[]): any[] {
647 if (!desktop) return lines.map(line => ui.Text({ color: RARITY_THEME[c.rarity], children: [line] }))
648 const w = Math.ceil(Math.max(...lines.map(l => l.length)) * 8.4) + 2
649 const h = lines.length * 17
650 const rows = lines.map((l, i) => `<text x="0" y="${i * 17 + 13}" xml:space="preserve">${esc(l)}</text>`).join('')
651 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>` })]
652}
653
654export function register(on: On) {
655 on('session.start', async ($, e, next) => {
656 const now = await $.clock.now()
657 feedSince = Math.floor(now / 1000)
658 home = (await $.env.get('HOME')) ?? ''
659 sessionId = await $.session.id()
660 const themeRow = (await $.config.list().catch(() => undefined))?.find(row => row.key === 'theme')
661 if (typeof themeRow?.value === 'string') theme = themeRow.value
662 await load($, now)
663 deferred = await oldCopyLive($, now)
664 if (deferred) muted = true
665 // A buddy that is off runs nothing at start: no process, no settings read.
666 if (!muted) {
667 stateDir = await resolveStateDir($)
668 // The fno mux sets both in each pane it hosts, and writes <mux dir>/<session>.visible.json.
669 const mux = await $.env.get('FNO_SESSION')
670 muxPane = Number(await $.env.get('FNO_PANE')) || 0
671 if (mux && muxPane && stateDir) muxVisible = `${(await $.env.get('FNO_MUX_DIR')) || `${stateDir}/mux`}/${mux}.visible.json`
672 const settings = await readSettings($)
673 wrapped = isOurs(settings?.statusLine)
674 if (wrapped && stateDir) await installWrapper($).catch(() => {})
675 else if (stateDir) {
676 const saved = await readJson($, `${buddyDir()}/inner.json`).catch(() => undefined)
677 // The user wrapped once, then ran /statusline again: ask, never re-wrap on their behalf.
678 if (saved && buddy) say(`your status line changed. /buddy statusline puts me back beside it.`, now)
679 }
680 }
681 $.clock.every(TICK_MS, async () => {
682 tick += 1
683 if (!buddy || muted) return
684 const at = await $.clock.now()
685 if (tick % 4 === 0) {
686 await readOnScreen($)
687 if (at - drawnAt < SEEN_MS && at - (bubble?.at ?? -Infinity) > IDLE_TALK_MS && attended(at) && (await claimIdle($, at))) {
688 // Stamp first so a slow call is not asked twice; the line shows when it arrives.
689 bubble = { text: '', at }
690 react($, 'idle').catch(() => {})
691 }
692 await syncSoul($).catch(() => {})
693 const was = wrapped
694 wrapped = (await wrapperSeen($, at)) || isOurs((await readSettings($))?.statusLine)
695 if (wrapped && !was) await $.ui.close({ id: PANE_ID }).catch(() => {})
696 if (wrapped) $.ui.invalidate('ui.render')
697 }
698 if (wrapped) {
699 drawnAt = at
700 await writeFrame($, at).catch(() => {})
701 } else if (at - drawnAt < SEEN_MS) $.ui.invalidate('ui.render')
702 })
703 $.clock.every(HATCH_FRAME_MS, async () => {
704 if ((await $.clock.now()) < hatchUntil) $.ui.invalidate('ui.render')
705 })
706 $.clock.every(FEED_MS, async () => readFeed($, await $.clock.now()))
707 $.clock.every(FLEET_MS / 5, async () => readFleet($, await $.clock.now()))
708 for (const name of COMMANDS) {
709 try {
710 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 })
711 } catch {
712 // A newer Claude Code may ship its own /buddy again; /bbb still works.
713 }
714 }
715 return next(e)
716 })
717
718 for (const command of COMMANDS) on('command.run', { command }, async ($, e) => {
719 const now = await $.clock.now()
720 const arg = e.args.trim().toLowerCase()
721 if (!buddy) await load($, now)
722 if (deferred) return { text: 'Your fno plugin still runs its own buddy. Update fno (/plugin update fno@footnote), then start a new session.' }
723 if (!stateDir) stateDir = await resolveStateDir($)
724 if (arg === 'statusline') return { text: await statuslineOn($) }
725 if (arg === 'bye') {
726 const { ok, text } = await statuslineOff($)
727 if (!ok) return { text }
728 muted = true
729 await $.store.set('muted', true)
730 await $.ui.close({ id: PANE_ID }).catch(() => {})
731 $.ui.invalidate('ui.render')
732 return { text: `${text ? text + ' ' : ''}Bye from ${buddy!.name}. It is gone from every session and makes no calls. /buddy on brings it back.` }
733 }
734 if (arg === 'pane' || arg === 'restore') {
735 const { ok, text } = await statuslineOff($)
736 if (!ok) return { text }
737 // Asked for by name, so the pane opens from 110 columns rather than the unasked 144.
738 paneAsked = true
739 await $.ui.open({ id: PANE_ID, title: buddy!.name, columns: PANE_COLUMNS }).catch(() => {})
740 return { text: `${text ? text + ' ' : ''}${buddy!.name} moved to a side pane. /buddy statusline brings it back.` }
741 }
742 if (arg === 'off') {
743 muted = true
744 await $.store.set('muted', true)
745 await $.ui.close({ id: PANE_ID })
746 $.ui.invalidate('ui.render')
747 return { text: `${buddy!.name} is napping. /buddy on wakes it.` }
748 }
749 if (arg === 'on') {
750 muted = false
751 paneAsked = false
752 await $.store.set('muted', false)
753 }
754 if (arg === 'roll') {
755 const r = await rerolls($, now)
756 if (r.bank < 1) return { text: `${buddy!.name} stays. ${rerollLine(r)}.` }
757 await $.store.set('rerolls', { ...r, bank: r.bank - 1 })
758 let soul = hatch(newSeed(), now)
759 for (let i = 0; i < 20 && soul.name === buddy!.name; i++) soul = hatch(newSeed(), now)
760 await $.store.set('soul', soul)
761 buddy = embody(soul)
762 recent = []
763 lastSaid = ''
764 personalityDone = false
765 void givePersonality($)
766 }
767 if (arg === 'pet') {
768 pettedAt = now
769 reactedAt = now
770 react($, 'pet', '(you were just petted)').catch(() => {})
771 }
772 const r = await rerolls($, now)
773 const fresh = (await $.store.get('hatchSeen')) !== buddy!.seed
774 if (fresh) {
775 await $.store.set('hatchSeen', buddy!.seed)
776 hatchUntil = now + 20_000
777 // Like the original, the hello comes once the soul is written, so it speaks as itself.
778 void (async () => {
779 for (let i = 0; i < 16 && !personalityDone; i++) await $.clock.sleep(500)
780 await react($, 'hatch', await projectContext($))
781 })().catch(() => {})
782 }
783 const snap: Shown = { c: buddy!, last: lastSaid, r, ...(fresh ? { hatchAt: now } : {}) }
784 try {
785 cardSnap = snap
786 await $.ui.open({ id: CARD_ID, title: buddy!.name, focus: true, closeOnEscape: true })
787 $.ui.invalidate('ui.render')
788 return { text: `${buddy!.name} the ${buddy!.species} · ${RARITY_STARS[buddy!.rarity]} ${buddy!.rarity}` }
789 } catch {
790 // No pane here (a narrow terminal, another app): the card draws in the output row instead.
791 cardSnap = undefined
792 pending = snap
793 $.ui.invalidate('ui.render')
794 return { text: (fresh ? HATCH_MARK : CARD_MARK) + card(buddy!, r) }
795 }
796 })
797
798 on('config.set', { key: 'theme' }, async ($, e, next) => {
799 if (typeof e.value === 'string') theme = e.value
800 return next(e)
801 })
802
803 on('prompt.edit', async ($, e, next) => {
804 typedAt = await $.clock.now()
805 return next(e)
806 })
807
808 on('turn.complete', async ($, e, next) => {
809 if (buddy && !muted && !e.agentId && !e.isAborted) {
810 const now = await $.clock.now()
811 if (now - drawnAt < SEEN_MS && attended(now)) {
812 const messages = await $.session.messages()
813 const why: Reason = addressedBy(lastPrompt(messages), buddy.name) ? 'addressed' : loudReason(turnOutput(messages)) ?? 'turn'
814 if (why !== 'turn' || now - reactedAt >= REACT_GAP_MS) {
815 reactedAt = now
816 // Not awaited: the next prompt must not wait on the buddy's model call.
817 react($, why).catch(() => {})
818 }
819 }
820 }
821 return next(e)
822 })
823
824 // The /buddy row: the card the original drew, or the hatch that leads into it.
825 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
826 desktop = e.surface === 'desktop'
827 const text = String(e.props.text ?? '')
828 if (!text.startsWith(CARD_MARK) && !text.startsWith(HATCH_MARK)) return next(e)
829 let snap = shown.get(e.requestId)
830 if (!snap) {
831 if (!pending) return next(e)
832 snap = pending
833 pending = undefined
834 shown.set(e.requestId, snap)
835 }
836 return showTree($.ui.resolve(e), snap, await $.clock.now(), false)
837 })
838
839 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
840 desktop = e.surface === 'desktop'
841 if (e.requestId !== CARD_ID || !cardSnap) return next(e)
842 const ui = $.ui.resolve(e)
843 const shut = () => {
844 cardSnap = undefined
845 void $.ui.close({ id: CARD_ID }).catch(() => {})
846 }
847 // An empty field holds the focus: any typed key or Enter closes the card, and Esc does too.
848 // Desktop draws an Input as a text box, so there the card closes with a button or Esc.
849 const close = e.surface === 'desktop'
850 ? ui.Button({ key: 'close', label: 'close', onPress: shut })
851 : ui.Input({ key: 'close', placeholder: 'press any key', value: '', submitLabel: 'close', autoFocus: true, onInput: shut, onSubmit: shut })
852 return showTree(ui, cardSnap, await $.clock.now(), close)
853 })
854
855 // The fallback when the status line is not wrapped: a narrow dock on the right,
856 // the buddy standing at the bottom and its words above it.
857 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
858 desktop = e.surface === 'desktop'
859 if (e.requestId !== PANE_ID || !buddy || muted) return next(e)
860 // One buddy on screen: a pane left open (a resumed session, a late open) closes once the status line has it.
861 if (wrapped) {
862 void $.ui.close({ id: PANE_ID }).catch(() => {})
863 return next(e)
864 }
865 const now = await $.clock.now()
866 drawnAt = paneDrawnAt = now
867 const { Box, Text, Button } = $.ui.resolve(e)
868 const color = RARITY_THEME[buddy.rarity]
869 const words = talking(now)
870 return Box({
871 flexDirection: 'column',
872 justifyContent: 'flex-end',
873 height: e.props.scroll?.bodyRows ?? 12,
874 children: [
875 ...(words ? [Box({ borderStyle: 'round', children: [Text({ wrap: 'wrap', children: [words] })] }), Text({ children: [' ◦ ·'] })] : []),
876 ...(fleet ? [Text({ dimColor: true, wrap: 'wrap', children: [fleet] }), Text({ children: [' '] })] : []),
877 ...drawArt({ Text, Svg: $.ui.resolve(e).Svg }, buddy, sprite(buddy, now)),
878 Button({ key: 'pet', label: buddy.name, hotkey: 'p', plain: true, dimColor: true, onPress: async () => {
879 pettedAt = await $.clock.now()
880 $.ui.invalidate('ui.render')
881 } }),
882 ],
883 })
884 })
885
886 // The band only holds a one-line face, and only where neither the status line nor the dock has the buddy.
887 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
888 desktop = e.surface === 'desktop'
889 // Desktop draws no status line but shares its settings, so a wrapped status line hides nothing there.
890 if (!buddy || muted || (wrapped && e.surface !== 'desktop') || e.props.hasSurvey) return next(e)
891 const now = await $.clock.now()
892 if (now - paneDrawnAt < SEEN_MS) return next(e)
893 if (!paneAsked && e.viewport?.isFullscreen === true) {
894 paneAsked = true
895 void $.ui.open({ id: PANE_ID, title: buddy.name, columns: PANE_COLUMNS }).catch(() => {})
896 }
897 drawnAt = now
898 const { Box, Text } = $.ui.resolve(e)
899 const words = talking(now)
900 const art = sprite(buddy, now)
901 // Desktop has room above the input: the full buddy stands at the right edge, its bubble to its left.
902 if (e.surface === 'desktop' && (e.props.maxRows ?? 0) > art.length) {
903 const color = RARITY_THEME[buddy.rarity]
904 const ours = Box({
905 flexDirection: 'row',
906 justifyContent: 'flex-end',
907 alignItems: 'flex-end',
908 children: [
909 ...(words ? [Box({ borderStyle: 'round', width: BUBBLE_COLUMNS, children: [Text({ wrap: 'wrap', children: [words] })] }), Text({ children: [' ◦ · '] })] : []),
910 Box({ flexDirection: 'column', alignItems: 'center', children: [...drawArt({ Text, Svg: $.ui.resolve(e).Svg }, buddy, art), Text({ bold: true, children: [buddy.name] })] }),
911 ],
912 })
913 const theirs = await next(e)
914 return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
915 }
916 const face = (now - pettedAt < PET_MS ? '♥ ' : '') + renderFace(buddy)
917 const ours = Text({ children: [Text({ color: RARITY_THEME[buddy.rarity], children: [`${face} ${buddy.name}`] }), ...(words ? [`: ${words}`] : [])] })
918 const theirs = await next(e)
919 return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
920 })
921}
922hooks/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 ticks, with a blink (-1) now and then. The status line
123// samples it once a second, so the original's mostly-resting loop looked frozen there.
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