SLOPSHOPPER

buddy

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

newpanebandrowscommandprompt
★ 2v0.0.5Apache-2.0updated 2026-10-08bllshttng/footnote/plugins/buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buddy
│ ┃ Dot ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────╮ │ ┃ │ │ ⏺ Read(src/auth.ts) │ ┃ │ ★★ UNCOMMON DUCK │ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ __ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ <(· )___ │ ⏺ Bash(bun test) │ ┃ │ ( ._> │ ⎿ 3 pass, 1 fail │ ┃ │ `--' │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Dot │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ "a duck with strong opinions" │ │ ┃ │ │ › /buddy │ ┃ │ DEBUGGING ███░░░░░░░ 32 │ ⎿ buddy: Dot the duck · ★★ uncommon │ ┃ │ PATIENCE ██░░░░░░░░ 20 │ │ ┃ │ CHAOS █░░░░░░░░░ 12 │ │ ┃ │ WISDOM █████████░ 90 │ │ ┃ │ SNARK █████░░░░░ 53 │ │ ┃ │ │ │ ┃ │ last said │ │ ┃ │ ╭────────────────────────────────╮ │ │ ┃ │ │ OK │ │ │ ┃ │ ╰────────────────────────────────╯ │ │ ┃ │ │ │ ┃ │ rerolls 1/3 │ (·> Dot: OK ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(·> Dot: OK ⟨Claude Code's own drawing⟩
Pane · Dot
╭──────────────────────────────────────╮ │ │ │ ★★ UNCOMMON DUCK │ │ │ │ __ │ │ <(· )___ │ │ ( ._> │ │ `--' │ │ │ │ Dot │ │ │ │ "a duck with strong opinions" │ │ │ │ DEBUGGING ███░░░░░░░ 32 │ │ PATIENCE ██░░░░░░░░ 20 │ │ CHAOS █░░░░░░░░░ 12 │ │ WISDOM █████████░ 90 │ │ SNARK █████░░░░░ 53 │ │ │ │ last said │ │ ╭────────────────────────────────╮ │ │ │ OK │ │ │ ╰────────────────────────────────╯ │ │ │ │ rerolls 1/3 │ │ │ ╰──────────────────────────────────────╯ press any key ⏎ close
Pane · Dot
╭──────────────────────────────────────────────────────────╮ │OK │ ╰──────────────────────────────────────────────────────────╯ ◦ · __ <(· )___ ( ._> `--' p: Dot
README

buddy

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.

Install

  1. Run /plugin install buddy@footnote.
  2. Type /buddy to watch it hatch and see its card. Press any key to close the card.
  3. Type /buddy statusline to put it beside your status line.

Commands

/buddy and /bbb are the same command.

CommandWhat it does
/buddyShow the card
/buddy petPet the buddy
/buddy rollRoll a new buddy (3 rerolls, refilled daily)
/buddy statuslineStand beside your status line
/buddy paneStand in a side pane (fullscreen layout)
/buddy off / onMute or unmute
/buddy byeRemove the buddy and restore your status line

Every observation goes to ~/.fno/state/buddy/observations.jsonl.

The full guide: docs/guides/buddy.md.

Source 4 files
hooks/register.ts 922 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
922
hooks/companion.ts 185 lines
1export 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}
185
hooks/sprites.ts 178 lines
1import 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']
178
hooks/voice.ts 215 lines
1import { 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