SLOPSHOPPER

buddy

Brings back the April Fools terminal buddy

newpanebandrowsguardcommand
★ 6v0.1.0no licenseupdated 2026-10-08avegancafe/Juliet/symlinked/home/.claude/mods/buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buddy
│ ┃ Wren ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ OK │ ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ┌──┐ │ ┃ ┌┘ └┐ › /buddy │ ┃ └┐ ┌┘ A legendary punk hatched: Wren! │ ┃ └──┘ │ ┃ Wren ★★★★★ LEGENDARY │ ┃ ♫ ♪ │ ┃ ♪ ♫ │ ┃ ♪ ┌────┐ │ ┃ │ ├┐ │ ┃ │☻ ☻ └│ │ ┃ │ Γ └┘ │ ┃ │⌐¬ │ │ ┃ │O │ │ ┃ “OK” │☻ ☻ └│ Wren ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
“OK” │☻ ☻ └│ Wren
Pane · Wren
╭──────────────────────────────────────────────────────────╮ │ OK │ ╰──────────────────────────────────────────────────────────╯ ┌──┐ ┌┘ └┐ └┐ ┌┘ └──┘ Wren
Command output
A legendary punk hatched: Wren! ★★★★★ LEGENDARY ♫ ♪ ♪ ♫ ♪ ┌────┐ │ ├┐ │☻ ☻ └│ │ Γ └┘ │⌐¬ │ │O │ │ │ └──┘ │ │ │ │ │ Wren "quietly judges your variable names" DEBUGGING ██████░░░░ 55 PATIENCE ██████████ 100 CHAOS ███████░░░ 74 WISDOM █████░░░░░ 53 SNARK ███████░░░ 71 seed 0x10f4bb34156c6bed9ff4b679e3ebfba4f737d81787ae13b050d98d8ec0da8985
README

Juliet

A comprehensive dotfiles repository for macOS development environment setup. Manages configurations for terminal applications, editors, system utilities, and development tools through symlinked dotfiles and automated setup scripts.

Setup

To set up Juliet, run:

$ git clone --recursive git@github.com:avegancafe/Juliet ~/.config/Juliet
$ ~/.config/Juliet/bin/juliet-bootstrap

Available Tools and Functions

System Setup & Management

Core Binaries:

  • juliet-bootstrap - Main setup and update script
  • Installs Homebrew and dependencies
  • Updates Juliet repository from git
  • Runs brew bundle to install packages
  • Sets up symlinks using GNU Stow
  • Usage: ~/.config/Juliet/bin/juliet-bootstrap

System Utilities:

  • reindex-spotlight - macOS Spotlight reindexing
  • update-kitty - Kitty terminal updater
  • envsource - Environment variable sourcing
  • sudo - Enhanced sudo wrapper

Git & Version Control

Git Binaries:

  • better-branch - Enhanced git branch viewer
  • Shows colorized branch list with commit counts
  • Displays ahead/behind status relative to main branch
  • Shows last commit info for each branch
  • delete-current-branch - Safe branch deletion
  • Switches to main branch and deletes current branch
  • Logs deleted commit SHA to ~/.delog for recovery
  • git-recreate - Branch recreation utility
  • Deletes current branch and recreates it from latest main
  • Useful for starting fresh while keeping branch name
  • git-hoard/ - Git repository organization tool (Node.js project)

Git Functions:

  • g - Short alias for git
  • current-branch - Get current git branch name
  • cb - Copy current branch name to clipboard
  • current-sha - Get current commit SHA
  • default-branch - Get repository's default branch
  • gdiff - Git diff wrapper
  • gut - Git status shortcut
  • clone - Enhanced git clone

Git Aliases:

  • git a - Add files to staging
  • git ape - Amend previous commit and force push with lease
  • git b / git bbranch - Show enhanced branch list (uses better-branch)
  • git clean-branches - Delete all branches except master/main
  • git clean-merged / git cm - Delete branches that have been merged
  • git co - Checkout branch/commit
  • git com - Commit changes
  • git cp - Cherry-pick commits
  • git del - Delete current branch safely (uses delete-current-branch)
  • git diff-branch - Show diff from fork point with master
  • git fixup - Create fixup commit for interactive rebase
  • git fo - Fetch from origin
  • git judge - Enhanced blame with whitespace/move detection
  • git main - Switch to main/master branch automatically
  • git o / git open - Open repository in GitHub
  • git pdiff - Pretty diff using bat with syntax highlighting
  • git pf - Push with force-with-lease (safer force push)
  • git pu - Pull changes
  • git rb - Rebase commits
  • git re - Restore files
  • git s - Switch branches
  • git squash - Interactive rebase to squash commits against main
  • git staash - Stash all changes including untracked files
  • git staged - Show staged changes
  • git su - Update submodules recursively
  • git t - Ticket (custom command)
  • git up - Push changes

Editor & Development Environment

Editor Tools:

  • nv - Launch Neovide GUI editor, auto-installs if missing
  • vim - Alias for nvim
  • cursor - Launch Cursor editor

Neovim Management:

  • nvim-refresh-plugins - Neovim plugin manager
  • Clears lazy.nvim plugin cache
  • Restores plugins from lock file

File Operations & Navigation

Enhanced File Tools:

  • cat - Enhanced cat using bat with syntax highlighting
  • ls - Enhanced ls using eza with better formatting

Workspace Management:

  • work - Interactive workspace/directory switcher
  • Reads from ~/.workspaces and ~/.workspaces__local
  • Supports @workspace (directories with subdirs) and @dir (specific dirs)
  • Uses gum for interactive selection
  • Setup example: `` @workspace ~/workspace @dir ~/.config/nvim ``

Development & DevOps Tools

GitHub Integration:

  • run - GitHub Actions workflow runner
  • Runs workflows with optional current branch
  • Watches workflow execution
  • Usage: run <workflow-name> [-c/--current] [-a/--args="args"]
  • gist-to-pdf - GitHub Gist converter
  • Clones gists and converts to PDF format
  • Usage: gist-to-pdf <gist-id>

Container & Cloud Tools:

  • dc - Docker Compose shortcut
  • kc - kubectl shortcut

Language-Specific Tools:

  • node - Node.js wrapper
  • has-pipfile - Check for Python Pipfile

Monitoring & Utilities

System Monitoring:

  • logs - System log viewer
  • watch - File watching utility
  • pv - Process viewer
  • st - Status checker

General Utilities:

  • note - Note-taking utility

Downloads

Architecture

  • symlinked/ - Contains all dotfiles organized by target location
  • symlinked/config/ - Files that go in ~/.config/
  • symlinked/home/ - Files that go in ~/
  • bin/ - Custom utility scripts and tools
  • etc/ - Assets (fonts, themes, wallpapers, keyboard layouts)
  • Brewfile - Homebrew package definitions

All Fish shell functions are auto-loaded alphabetically from symlinked/config/fish/functions/aliases/ and integrate with the broader Juliet ecosystem for a cohesive development environment.

Source 5 files
hooks/register.tsx 301 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pet, Stored } from '../types'
5
6import { bones, bubbleLine, card, color, face, NAMES, newSeed, peakStat, pick, sprite, voice, wrap } from './companion'
7import type { Fit } from './companion'
8
9const pet = atom({ plugin: 'buddy', key: 'pet' } as const, null)
10const tick = atom({ plugin: 'buddy', key: 'tick' } as const, 0)
11const says = atom({ plugin: 'buddy', key: 'says' } as const, null)
12const isHidden = atom({ plugin: 'buddy', key: 'isHidden' } as const, false)
13const isMuted = atom({ plugin: 'buddy', key: 'isMuted' } as const, false)
14
15// The punk lives in a narrow pane docked beside the transcript, so it narrows the
16// conversation instead of pushing it up; the band above the prompt is its fallback.
17const PANE = 'buddy'
18const PANE_COLUMNS = 16
19const TICK_MS = 500
20const BUBBLE_MS = 10_000
21const PET_MS = 2_500
22const HATCH_MS = 3_000
23// ponytail: fixed model-call cooldown; make it an option if it's too chatty or too quiet
24const CHIRP_COOLDOWN_MS = 20_000
25
26
27let bubbleUntil = 0
28let petUntil = 0
29let hatchUntil = 0
30let lastChirpAt = -Infinity
31// The bubble as last drawn on screen, which the model is told to fit (pane or one-row band).
32let fit: Fit = { width: 12, lines: 6 }
33const MAX_BUBBLE_LINES = 6
34let lastPrompt = ''
35
36async function say($: EngineInterface, text: string) {
37  if (await read($, isMuted)) return
38  bubbleUntil = (await $.clock.now()) + BUBBLE_MS
39  await update($, says, () => text)
40}
41
42async function chirp($: EngineInterface, situation: string, isForced = false) {
43  const mine = await read($, pet)
44  const now = await $.clock.now()
45  if (!mine || (await read($, isHidden)) || (await read($, isMuted))) return
46  if (!isForced && now - lastChirpAt < CHIRP_COOLDOWN_MS) return
47  lastChirpAt = now
48  // Runs unawaited from hooks, so a failed call must not escape as an unhandled rejection.
49  try {
50    const ask = (prompt: string) =>
51      $.model.complete({ model: 'haiku', maxTokens: 80, effort: 'low', system: voice(mine, fit), prompt })
52    const fits = (line: string) => wrap(line, fit.width).length <= fit.lines
53    const r = await ask(situation)
54    let line = r.isAnswered ? bubbleLine(r.text) : null
55    // Too long for the bubble: hand it back once with the measurements and ask again.
56    if (line && !fits(line)) {
57      const retry = await ask(
58        `${situation}\n\nYou said: ${line}\nThat needs ${wrap(line, fit.width).length} lines, but your bubble holds ` +
59          `${fit.lines} line${fit.lines === 1 ? '' : 's'} of ${fit.width} characters. Say it again, shorter.`,
60      )
61      const shorter = retry.isAnswered ? bubbleLine(retry.text) : null
62      if (shorter && (fits(shorter) || shorter.length < line.length)) line = shorter
63    }
64    if (line) await say($, line)
65  } catch {
66    // no line this time; the bubble stays as it was
67  }
68}
69
70async function hatch($: EngineInterface): Promise<Pet> {
71  hatchUntil = (await $.clock.now()) + HATCH_MS
72  await update($, isHidden, () => false)
73  await $.store.set('isHidden', false)
74  const body = bones(newSeed())
75  let fresh: Pet = { ...body, name: pick(NAMES), personality: 'quietly judges your variable names' }
76  await update($, pet, () => fresh)
77  const stats = Object.entries(body.stats).sort((a, b) => a[1] - b[1])
78  const r = await $.model.complete({
79    model: 'haiku',
80    maxTokens: 100,
81    effort: 'low',
82    system: 'You name tiny terminal pets. Reply with only a JSON object.',
83    prompt:
84      `A ${body.rarity} ASCII punk (a little box-drawn face, after the ASCIIPunks NFTs) just hatched in a developer's terminal. Strongest stat ${peakStat(body)}, ` +
85      `weakest ${stats[0]![0]}. Reply {"name": "<short cute name>", "personality": "<one lowercase sentence under 12 words, specific and a little odd>"}`,
86  })
87  try {
88    const soul = r.isAnswered ? JSON.parse(r.text.match(/\{[\s\S]*\}/)?.[0] ?? '') : null
89    if (typeof soul?.name === 'string' && typeof soul?.personality === 'string') {
90      fresh = { ...fresh, name: soul.name.slice(0, 16), personality: soul.personality.slice(0, 100) }
91    }
92  } catch {
93    // keep the fallback name and personality
94  }
95  const stored: Stored = { seed: fresh.seed, name: fresh.name, personality: fresh.personality }
96  await $.store.set('pet', stored)
97  await update($, pet, () => fresh)
98  await say($, `hi! i'm ${fresh.name}.`)
99  void chirp($, 'You just hatched out of an egg in this terminal. Introduce yourself.', true)
100  return fresh
101}
102
103async function openPane($: EngineInterface) {
104  const mine = await read($, pet)
105  if (mine && !(await read($, isHidden))) await $.ui.open({ id: PANE, title: mine.name, columns: PANE_COLUMNS })
106}
107
108export const register: Register = on => {
109  on('session.start', async ($, e, next) => {
110    const saved = (await $.store.get('pet')) as Partial<Stored> | undefined
111    if (saved?.name) {
112      // A buddy from before seeds keeps its name and gets one.
113      const seed = saved.seed ?? newSeed()
114      const soul: Stored = { seed, name: saved.name, personality: saved.personality ?? 'quietly judges your variable names' }
115      if (seed !== saved.seed) await $.store.set('pet', soul)
116      // Bones win over anything stored, as the original's `{ ...stored, ...bones }` did.
117      const mine: Pet = { ...soul, ...bones(seed) }
118      await update($, pet, () => mine)
119    }
120    const [hidden, muted] = await Promise.all([$.store.get('isHidden'), $.store.get('isMuted')])
121    await update($, isHidden, () => hidden === true)
122    await update($, isMuted, () => muted === true)
123    await $.command.register({ name: 'buddy', description: 'Your terminal buddy: pet, card, mute, unmute, off, on, hatch' })
124    // No auto-open: the pane waits for `/buddy` or `/buddy on`; until then the band carries the face.
125    $.clock.every(TICK_MS, async () => {
126      await update($, tick, n => n + 1)
127      if (bubbleUntil && (await $.clock.now()) > bubbleUntil) {
128        bubbleUntil = 0
129        await update($, says, () => null)
130      }
131    })
132    return next(e)
133  })
134
135  on('command.run', { command: 'buddy' }, async ($, e) => {
136    const arg = e.args.trim().toLowerCase()
137    const mine = await read($, pet)
138    if (!mine || arg === 'hatch') {
139      const fresh = await hatch($)
140      await openPane($)
141      return { text: `A ${fresh.rarity} punk hatched: ${fresh.name}!\n\n${card(fresh)}` }
142    }
143    if (arg === 'off' || arg === 'on') {
144      await update($, isHidden, () => arg === 'off')
145      await $.store.set('isHidden', arg === 'off')
146      if (arg === 'off') await $.ui.close({ id: PANE })
147      else await openPane($)
148      return { text: arg === 'off' ? `${mine.name} is napping. /buddy on to wake them.` : `${mine.name} is back!` }
149    }
150    if (arg === 'mute' || arg === 'unmute') {
151      await update($, isMuted, () => arg === 'mute')
152      await $.store.set('isMuted', arg === 'mute')
153      if (arg === 'mute') await update($, says, () => null)
154      return { text: arg === 'mute' ? `${mine.name} will keep their thoughts to themselves.` : `${mine.name} has opinions again.` }
155    }
156    if (arg === 'pet') {
157      petUntil = (await $.clock.now()) + PET_MS
158      await update($, isHidden, () => false)
159      await say($, '♥')
160      void chirp($, 'The developer just petted you.', true)
161      return { text: `You pet ${mine.name}.` }
162    }
163    await openPane($)
164    return { text: card(mine) }
165  })
166
167  on('prompt.compose', async ($, e, next) => {
168    const result = await next(e)
169    const mine = await read($, pet)
170    if (!mine || (await read($, isHidden))) return result
171    const text =
172      `A small ASCII punk named ${mine.name} sits beside the user's input box and occasionally comments in a speech bubble. ` +
173      `You are not ${mine.name}; it is a separate watcher with its own voice. When the user addresses ${mine.name} directly, ` +
174      `its bubble answers. Keep your own reply to one short line then, or answer only the part meant for you. ` +
175      `Don't explain that you aren't ${mine.name}, and never speak for it.`
176    return { ...result, sections: [...result.sections, { id: 'buddy:companion', text, scope: 'session' as const }] }
177  })
178
179  on('prompt.submit', async ($, e, next) => {
180    lastPrompt = e.text
181    const mine = await read($, pet)
182    if (mine && new RegExp(`\\b${mine.name.replace(/\W/g, '')}\\b`, 'i').test(e.text)) {
183      void chirp($, `The developer just said to you: "${e.text.slice(0, 500)}". Answer them.`, true)
184    }
185    return next(e)
186  })
187
188  on('tool.call', async ($, e, next) => {
189    const ran = await next(e)
190    if (!e.agentId && 'isError' in ran && ran.isError) {
191      const text = 'text' in ran && typeof ran.text === 'string' ? ran.text.slice(0, 300) : ''
192      void chirp($, `Claude's ${e.tool} call just failed${text ? `: ${text}` : ''}. React.`)
193    }
194    return ran
195  })
196
197  on('turn.complete', async ($, e, next) => {
198    if (!e.agentId && !e.isAborted) {
199      void chirp(
200        $,
201        `The developer asked Claude: "${lastPrompt.slice(0, 400)}"\n` +
202          `After ${Math.round(e.durationMs / 1000)}s Claude answered: "${e.answer.slice(0, 600)}"\nReact.`,
203      )
204    }
205    return next(e)
206  })
207
208  // The card is art: drawn line by line, so the row keeps its leading spaces and blank lines.
209  on('ui.render', { component: 'CommandOutput', props: { command: 'buddy' } }, ($, e) => {
210    const { Box, Text } = $.ui.resolve(e)
211    return (
212      <Box flexDirection="column">
213        {e.props.text.split('\n').map((line, i) => (
214          <Text key={`c${i}`}>{line || ' '}</Text>
215        ))}
216      </Box>
217    )
218  })
219
220  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221    const mine = await read($, pet)
222    if (e.props.hasSurvey || !mine || (await read($, isHidden))) return next(e)
223
224    const { Box, Text } = $.ui.resolve(e)
225    const n = await read($, tick)
226    const now = await $.clock.now()
227    const bubble = (await read($, isMuted)) ? null : await read($, says)
228    const tint = color(mine, n)
229
230    // The pane has the punk while it's on screen; otherwise the band carries a one-line face.
231    const shown = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
232    if (shown) return next(e)
233    fit = { width: Math.max(12, e.props.bodyColumns - [...face(mine)].length - mine.name.length - 6), lines: 1 }
234    return (
235      <Box flexDirection="row" justifyContent="flex-end">
236        {bubble ? <Text wrap="truncate-end">“{bubble}” </Text> : null}
237        <Text color={tint}>{face(mine)} </Text>
238        <Text dimColor>{mine.name}</Text>
239      </Box>
240    )
241  })
242
243  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
244    const mine = await read($, pet)
245    if (!mine) return next(e)
246
247    const els = $.ui.resolve(e)
248    const { Box, Text } = els
249    const n = await read($, tick)
250    const now = await $.clock.now()
251    const bubble = (await read($, isMuted)) ? null : await read($, says)
252    const tint = color(mine, n)
253    const lines = sprite(mine, { tick: n, isPetting: now < petUntil, isHatching: now < hatchUntil })
254    // The bubble gets the pane's width less its border and padding, and whatever rows
255    // the punk and its name leave, so the whole quip shows.
256    const width = Math.max(8, e.props.bodyColumns - 4)
257    const room = Math.max(1, e.props.scroll.bodyRows - lines.length - 1 - 2)
258    fit = { width, lines: Math.min(room, MAX_BUBBLE_LINES) }
259
260    // Bottom-aligned, so the punk sits next to the prompt like the original sat beside the input.
261    return (
262      <Box flexDirection="column" justifyContent="flex-end" alignItems="center" height={e.props.scroll.bodyRows}>
263        {bubble ? (
264          <Box key="bubble" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
265            {wrap(bubble, width, room).map((l, i) => (
266              <Text key={`b${i}`}>{l}</Text>
267            ))}
268          </Box>
269        ) : null}
270        {e.surface !== 'terminal' && 'Svg' in els ? (
271          // Text draws in the app's proportional font off-terminal, which shears the punk's columns.
272          <els.Svg key="sprite" source={spriteSvg(lines, tint)} alt={`${mine.name}, a pixel punk`} />
273        ) : (
274          lines.map((l, i) => (
275            <Text key={`s${i}`} color={tint}>
276              {l}
277            </Text>
278          ))
279        )}
280        <Text key="name" dimColor>
281          {mine.name}
282        </Text>
283      </Box>
284    )
285  })
286}
287
288// The sprite as monospace SVG text, for surfaces whose Text is not drawn in a terminal cell grid.
289function spriteSvg(lines: string[], tint: string): string {
290  const size = 14
291  const lineHeight = Math.round(size * 1.2)
292  const cols = Math.max(1, ...lines.map(l => [...l].length))
293  const width = Math.ceil(cols * size * 0.6) + 2
294  const height = lines.length * lineHeight + 4
295  const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
296  const rows = lines
297    .map((l, i) => `<text x="1" y="${(i + 1) * lineHeight}" xml:space="preserve">${esc(l)}</text>`)
298    .join('')
299  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="${size}" fill="${tint}" style="white-space:pre">${rows}</svg>`
300}
301
hooks/companion.ts 206 lines
1import type { Bones, Pet } from '../types'
2
3import { hex, keccak256, unhex } from './keccak'
4import {
5  PUNK_CHIN, PUNK_FOREHEAD, PUNK_LEFT_EYES, PUNK_LEFT_FACE, PUNK_MOUTHS, PUNK_NECK, PUNK_NOSE_LEFT,
6  PUNK_NOSE_RIGHT, PUNK_NOSES, PUNK_RIGHT_EYES, PUNK_RIGHT_FACE, PUNK_TOPS,
7} from './punk-parts'
8
9export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const
10const WEIGHTS = [60, 25, 10, 4, 1]
11const FLOORS = [5, 15, 25, 35, 50]
12export const STATS = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'] as const
13export const RARITY_COLOR = ['gray', 'green', 'blue', 'magenta', 'yellow']
14const RAINBOW = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta']
15
16export const NAMES = ['Pip', 'Mochi', 'Bean', 'Sprout', 'Nugget', 'Biscuit', 'Wren', 'Tofu', 'Pickle', 'Gizmo', 'Dumpling', 'Sprocket']
17
18// The egg sits where the punk's chin and neck will be.
19const EGG = [
20  ['    ┌──┐    ', '   ┌┘  └┐   ', '   └┐  ┌┘   ', '    └──┘    '],
21  ['    ┌──┐    ', '   ┌┘╲╱└┐   ', '   └┐  ┌┘   ', '    └──┘    '],
22  ['    ┌╲╱┐    ', '   ┌┘╲╱└┐   ', '   └┐╱╲┌┘   ', '    └──┘    '],
23]
24const HEARTS = ['   ♥    ♥   ', '  ♥  ♥  ♥   ', ' ♥   ♥   ♥  ', '   ♥  ♥     ']
25// Every 500ms tick: true blinks.
26const BLINKS = [8, 18]
27const IDLE_TICKS = 20
28
29export function pick<T>(xs: readonly T[], rand = Math.random): T {
30  return xs[Math.floor(rand() * xs.length)]!
31}
32
33export function newSeed(): string {
34  return hex(crypto.getRandomValues(new Uint8Array(32)))
35}
36
37/** The contract's `uint256(keccak256(abi.encodePacked(seed)))`: the seed's 32 bytes, hashed once. */
38const hash = (seed: string) => BigInt(`0x${hex(keccak256(unhex(seed)))}`)
39
40const slot = (rand: bigint, count: number) => Number(rand % BigInt(count))
41
42/** Everything about a buddy but its name and personality, from its seed alone. */
43export function bones(seed: string): Bones {
44  const rand = hash(seed)
45  // As in the contract, every part reads the same `rand`; rarity and stats
46  // read its higher bytes so they don't move in step with the face.
47  let roll = slot(rand >> 128n, 100)
48  let tier = 0
49  while (tier < WEIGHTS.length - 1 && roll >= WEIGHTS[tier]!) roll -= WEIGHTS[tier++]!
50  const eye = slot(rand, PUNK_LEFT_EYES.length)
51  const floor = FLOORS[tier]!
52  const byte = (i: number) => slot(rand >> BigInt(136 + i * 8), 256)
53  const order = STATS.map((s, i) => [s, byte(i)] as const).sort((a, b) => a[1] - b[1]).map(([s]) => s)
54  const stats = Object.fromEntries(
55    STATS.map((s, i) => {
56      const jitter = byte(5 + i) / 256
57      const value =
58        s === order[4] ? (tier === 4 ? 100 : Math.min(100, floor + 50 + Math.floor(jitter * 30)))
59        : s === order[0] ? floor + Math.floor(jitter * 10)
60        : floor + Math.floor(jitter * 40)
61      return [s, value]
62    }),
63  )
64  return {
65    seed,
66    rarity: RARITIES[tier]!,
67    top: PUNK_TOPS[slot(rand, PUNK_TOPS.length)]!,
68    eyes: [PUNK_LEFT_EYES[eye]!, PUNK_RIGHT_EYES[eye]!],
69    nose: PUNK_NOSES[slot(rand, PUNK_NOSES.length)]!,
70    mouth: PUNK_MOUTHS[slot(rand, PUNK_MOUTHS.length)]!,
71    isShiny: slot(rand >> 64n, 100) === 0,
72    stats,
73  }
74}
75
76export function peakStat(pet: Bones): string {
77  return Object.entries(pet.stats).sort((a, b) => b[1] - a[1])[0]![0]
78}
79
80/** The contract's `draw(seed)`, line for line: top, forehead, eyes, nose, mouth, chin, neck. */
81export function punk(pet: Bones, isBlinking = false): string[] {
82  const [l, r] = isBlinking ? ['─', '─'] : pet.eyes
83  return [
84    ...pet.top,
85    PUNK_FOREHEAD,
86    `${PUNK_LEFT_FACE}${l} ${r}${PUNK_RIGHT_FACE}`,
87    `${PUNK_NOSE_LEFT}${pet.nose}${PUNK_NOSE_RIGHT}`,
88    ...pet.mouth,
89    ...PUNK_CHIN,
90    ...PUNK_NECK,
91  ]
92}
93
94export type Moment = { tick: number; isPetting: boolean; isHatching: boolean }
95
96/** The punk for this moment: blinking now and then, hearts while petted, an egg while hatching. */
97export function sprite(pet: Bones, m: Moment): string[] {
98  if (m.isHatching) return [...Array(8).fill(' '.repeat(12)), ...EGG[m.tick % EGG.length]!]
99  const lines = punk(pet, BLINKS.includes(m.tick % IDLE_TICKS))
100  if (m.isPetting) lines[0] = HEARTS[m.tick % HEARTS.length]!
101  return lines
102}
103
104/** One-line face for a short or narrow band: the eye line. */
105export function face(pet: Bones): string {
106  return punk(pet)[4]!.trim()
107}
108
109export function color(pet: Bones, tick: number): string {
110  return pet.isShiny ? RAINBOW[tick % RAINBOW.length]! : RARITY_COLOR[RARITIES.indexOf(pet.rarity)]!
111}
112
113export function card(pet: Pet): string {
114  const tier = RARITIES.indexOf(pet.rarity)
115  const bars = STATS.map(s => {
116    const v = pet.stats[s] ?? 0
117    return `${s.padEnd(10)} ${'█'.repeat(Math.round(v / 10)).padEnd(10, '░')} ${v}`
118  })
119  return [
120    `${'★'.repeat(tier + 1)} ${pet.rarity.toUpperCase()}${pet.isShiny ? '  ✨ SHINY' : ''}`,
121    ...punk(pet),
122    pet.name,
123    `"${pet.personality}"`,
124    '',
125    ...bars,
126    '',
127    `seed 0x${pet.seed}`,
128  ].join('\n')
129}
130
131/**
132 * Wraps a bubble's text to `width` columns and at most `maxLines` lines: a word
133 * longer than a line is broken, and text past the last line ends it with `…`.
134 */
135export function wrap(text: string, width: number, maxLines = Infinity): string[] {
136  const out: string[] = []
137  let line = ''
138  for (let word of text.split(/\s+/).filter(Boolean)) {
139    while ([...word].length > width) {
140      if (line) out.push(line)
141      out.push([...word].slice(0, width).join(''))
142      word = [...word].slice(width).join('')
143      line = ''
144    }
145    if (line && [...line].length + [...word].length + 1 > width) {
146      out.push(line)
147      line = word
148    } else line = line ? `${line} ${word}` : word
149  }
150  if (line) out.push(line)
151  if (out.length <= maxLines) return out
152  const kept = out.slice(0, Math.max(1, maxLines))
153  kept[kept.length - 1] = `${[...kept[kept.length - 1]!].slice(0, width - 1).join('')}…`
154  return kept
155}
156
157// What each stat means for how the buddy talks, from its lowest to its highest reading.
158const TRAITS: Record<string, [low: string, high: string]> = {
159  DEBUGGING: ['has no idea how code works and guesses wildly', 'spots the actual bug and names the concrete fix'],
160  PATIENCE: ['is openly impatient about slow turns and long waits', 'is serene, unhurried, and encouraging'],
161  CHAOS: ['is orderly and literal', 'goes on odd tangents and non sequiturs'],
162  WISDOM: ['says naive, obvious things', 'drops a real insight about the work'],
163  SNARK: ['is earnest and sweet', 'is dry, sarcastic, and teasing'],
164}
165
166const degree = (v: number) => (v >= 80 ? 'extremely' : v >= 60 ? 'clearly' : v >= 35 ? 'a bit' : 'not at all')
167
168/** The system prompt that makes a buddy sound like its own stats, personality and face. */
169export type Fit = { width: number; lines: number }
170
171export function voice(pet: Pet, fit: Fit): string {
172  const ranked = [...STATS].sort((a, b) => (pet.stats[b] ?? 0) - (pet.stats[a] ?? 0))
173  const traits = STATS.map(s => {
174    const v = pet.stats[s] ?? 0
175    const [low, high] = TRAITS[s]!
176    return `- ${s} ${v}/100: ${v >= 50 ? `${degree(v)} — ${high}` : `${degree(100 - v)} the opposite — ${low}`}`
177  }).join('\n')
178  return [
179    `You are ${pet.name}, a ${pet.rarity}${pet.isShiny ? ', shiny' : ''} ASCII punk: a tiny box-drawn face that lives in a`,
180    "pane beside a developer's terminal, watching them work with Claude. This is you:",
181    '',
182    ...punk(pet),
183    '',
184    `Personality: ${pet.personality}`,
185    'How your stats shape your voice:',
186    traits,
187    `Lean hardest into ${ranked[0]}; your ${ranked[4]} barely shows.`,
188    '',
189    'You are not Claude and not an assistant; you are a pet with opinions. Reply with ONE speech-bubble line:',
190    'lowercase, no quotes, no emoji, no hashtags. React to what just happened in character.',
191    `Your speech bubble is ${fit.width} characters wide and ${fit.lines} line${fit.lines === 1 ? '' : 's'} tall, wrapped at word breaks,`,
192    `so keep the whole line under about ${Math.floor(fit.width * fit.lines * 0.8)} characters. Shorter is better; never trail off.`,
193    'Be specific to the situation, never generic. Your look (eyes, hat, mouth) may color what you say.',
194    'Words only: never draw, never repeat your picture, no code fences.',
195  ].join('\n')
196}
197
198/** The one plain line of a reply that may go in the bubble: no art, fences or quotes; null if none. */
199export function bubbleLine(reply: string): string | null {
200  const line = reply
201    .split('\n')
202    .map(l => l.trim())
203    .find(l => l && !l.startsWith('```') && !/[\u2500-\u259F\u25A0-\u25FF]/.test(l))
204  return line ? line.replace(/^["'“]|["'”]$/g, '') : null
205}
206
hooks/keccak.ts 61 lines
1// keccak256, as the ASCIIPunks contract hashes its seed (Ethereum's Keccak,
2// 0x01 padding, not NIST SHA3-256). Lanes are 64-bit BigInts: slow, but it
3// runs once per load, never per frame.
4
5const MASK = (1n << 64n) - 1n
6const RC = [
7  0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
8  0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
9  0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
10  0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
11  0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
12  0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
13]
14// Rotation offsets, indexed x + 5y.
15const ROT = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14]
16const RATE = 136
17
18const rotl = (v: bigint, n: number) => (n === 0 ? v : ((v << BigInt(n)) | (v >> BigInt(64 - n))) & MASK)
19
20function permute(s: bigint[]) {
21  for (const rc of RC) {
22    const c = [0, 1, 2, 3, 4].map(x => s[x]! ^ s[x + 5]! ^ s[x + 10]! ^ s[x + 15]! ^ s[x + 20]!)
23    for (let x = 0; x < 5; x++) {
24      const d = c[(x + 4) % 5]! ^ rotl(c[(x + 1) % 5]!, 1)
25      for (let y = 0; y < 25; y += 5) s[x + y] = s[x + y]! ^ d
26    }
27    const b: bigint[] = new Array(25)
28    for (let x = 0; x < 5; x++) {
29      for (let y = 0; y < 5; y++) b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(s[x + 5 * y]!, ROT[x + 5 * y]!)
30    }
31    for (let i = 0; i < 25; i++) {
32      const x = i % 5
33      const row = i - x
34      s[i] = b[i]! ^ (~b[row + ((x + 1) % 5)]! & MASK & b[row + ((x + 2) % 5)]!)
35    }
36    s[0] = s[0]! ^ rc
37  }
38}
39
40export function keccak256(data: Uint8Array): Uint8Array {
41  const padded = new Uint8Array(Math.ceil((data.length + 1) / RATE) * RATE)
42  padded.set(data)
43  padded[data.length] = 0x01
44  padded[padded.length - 1] = 0x80 | padded[padded.length - 1]!
45  const s: bigint[] = new Array(25).fill(0n)
46  for (let block = 0; block < padded.length; block += RATE) {
47    for (let lane = 0; lane < RATE / 8; lane++) {
48      let v = 0n
49      for (let k = 7; k >= 0; k--) v = (v << 8n) | BigInt(padded[block + lane * 8 + k]!)
50      s[lane] = s[lane]! ^ v
51    }
52    permute(s)
53  }
54  const out = new Uint8Array(32)
55  for (let i = 0; i < 32; i++) out[i] = Number((s[i >> 3]! >> BigInt((i & 7) * 8)) & 0xffn)
56  return out
57}
58
59export const hex = (bytes: Uint8Array) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
60export const unhex = (text: string) => Uint8Array.from(text.match(/../g) ?? [], b => parseInt(b, 16))
61
hooks/punk-parts.ts 23 lines
1// ASCIIPunks' slot lists, extracted verbatim by script from
2// github.com/asciilabs/asciipunks--contract contracts/AsciiPunkFactory.sol
3// (MIT, Copyright (c) 2021 asciipunks). Don't hand-edit: these are the
4// contract's own parts, line for line, trailing spaces included.
5
6export const PUNK_TOPS: string[][] = [["   ┌───┐    ", "   │   ┼┐   ", "   ├────┼┼  "], ["   ┌┬┬┬┬┐   ", "   ╓┬┬┬┬╖   ", "   ╙┴┴┴┴╜   "], ["   ╒════╕   ", "  ┌┴────┴┐  ", "  └┬────┬┘  "], ["   ╒════╕   ", "   │□□□□│   ", "  └┬────┬┘  "], ["   ╒════╕   ", "   │    │   ", " └─┬────┬─┘ "], ["    ◙◙◙◙    ", "   ▄████▄   ", "   ┌────┐   "], ["   ┌───┐    ", "┌──┤   └┐   ", "└──┼────┤   "], ["    ┌───┐   ", "   ┌┘   ├──┐", "   ├────┼──┘"], ["   ┌────┐/  ", "┌──┴────┴──┐", "└──┬────┬──┘"], ["   ╒════╕   ", " ┌─┴────┴─┐ ", " └─┬────┬─┘ "], ["  ┌──────┐  ", "  │▲▲▲▲▲▲│  ", "  └┬────┬┘  "], ["  ┌┌────┐┐  ", "  ││┌──┐││  ", "  └┼┴──┴┼┘  "], ["   ┌────┐   ", "  ┌┘─   │   ", "  └┌────┐   "], ["            ", "   ┌┬┬┬┬┐   ", "   ├┴┴┴┴┤   "], ["            ", "    ╓┬╥┐    ", "   ┌╨┴╨┴┐   "], ["            ", "   ╒╦╦╦╦╕   ", "   ╞╩╩╩╩╡   "], ["            ", "            ", "   ┌┼┼┼┼┐   "], ["            ", "    ││││    ", "   ┌┼┼┼┼┐   "], ["      ╔     ", "     ╔║     ", "   ┌─╫╫─┐   "], ["            ", "    ║║║║    ", "   ┌╨╨╨╨┐   "], ["            ", "   ▐▐▐▌▌▌   ", "   ┌────┐   "], ["            ", "   \\/////   ", "   ┌────┐   "], ["    ┐ ┌     ", "   ┐││││┌   ", "   ┌────┐   "], ["  ┌┐ ┐┌┐┌┐  ", "  └└┐││┌┘   ", "   ┌┴┴┴┴┐   "], ["  ┐┐┐┐┐     ", "  └└└└└┐    ", "   └└└└└┐   "], ["            ", "   ││││││   ", "   ┌────┐   "], ["            ", "    ╓╓╓╓    ", "   ┌╨╨╨╨┐   "], ["    ╔╔╗╗╗   ", "   ╔╔╔╗╗╗╗  ", "  ╔╝╝║ ╚╚╗  "], ["   ╔╔╔╔╔╗   ", "  ╔╔╔╔╔╗║╗  ", "  ╝║╨╨╨╨║╚  "], ["   ╔╔═╔═╔   ", "   ╔╩╔╩╔╝   ", "   ┌────┐   "], ["            ", "     ///    ", "   ┌────┐   "], ["     ╔╗╔╗   ", "    ╔╗╔╗╝   ", "   ┌╔╝╔╝┐   "], ["     ╔╔╔╔╝  ", "    ╔╝╔╝    ", "   ┌╨╨╨─┐   "], ["       ╔╗   ", "    ╔╔╔╗╝   ", "   ┌╚╚╝╝┐   "], ["   ╔════╗   ", "  ╔╚╚╚╝╝╝╗  ", "  ╟┌────┐╢  "], ["    ╔═╗     ", "    ╚╚╚╗    ", "   ┌────┐   "], ["            ", "            ", "   ┌╨╨╨╨┐   "], ["            ", "    ⌂⌂⌂⌂    ", "   ┌────┐   "], ["   ┌────┐   ", "   │   /└┐  ", "   ├────┐/  "], ["            ", "   ((((((   ", "   ┌────┐   "], ["   ┌┌┌┌┌┐   ", "   ├┘┘┘┘┘   ", "   ┌────┐   "], ["   «°┐      ", "    │╪╕     ", "   ┌└┼──┐   "], ["  <° °>   § ", "   \\'/   /  ", "   {())}}   "], ["   ██████   ", "  ██ ██ ██  ", " █ ██████ █ "], ["    ████    ", "   ██◙◙██   ", "   ┌─▼▼─┐   "], ["   ╓╖  ╓╖   ", "  °╜╚╗╔╝╙°  ", "   ┌─╨╨─┐   "], ["   ± ±± ±   ", "   ◙◙◙◙◙◙   ", "   ┌────┐   "], ["  ♫     ♪   ", "    ♪     ♫ ", " ♪ ┌────┐   "], ["    /≡≡\\    ", "   /≡≡≡≡\\   ", "  /┌────┐\\  "], ["            ", "   ♣♥♦♠♣♥   ", "   ┌────┐   "], ["     [⌂]    ", "      │     ", "   ┌────┐   "], ["  /\\/\\/\\/\\  ", "  \\\\/\\/\\//  ", "   ┌────┐   "], ["    ↑↑↓↓    ", "   ←→←→AB   ", "   ┌────┐   "], ["    ┌─┬┐    ", "   ┌┘┌┘└┐   ", "   ├─┴──┤   "], ["    ☼  ☼    ", "     \\/     ", "   ┌────┐   "]]
7
8export const PUNK_LEFT_EYES: string[] = ["◕", "*", "♥", "X", "⊙", "˘", "α", "◉", "☻", "¬", "^", "═", "┼", "┬", "■", "─", "û", "╜", "δ", "│", "┐", "┌", "┌", "╤", "/", "\\", "/", "\\", "╦", "♥", "♠", "♦", "╝", "◄", "►", "◄", "►", "I", "╚", "╔", "╙", "╜", "╓", "╥", "$", "○", "N", "x"]
9
10export const PUNK_RIGHT_EYES: string[] = ["◕", "*", "♥", "X", "⊙", "˘", "α", "◉", "☻", "¬", "^", "═", "┼", "┬", "■", "─", "û", "╜", "δ", "│", "┐", "┐", "┌", "╤", "\\", "/", "/", "\\", "╦", "♠", "♣", "♦", "╝", "►", "◄", "◄", "◄", "I", "╚", "╗", "╜", "╜", "╓", "╥", "$", "○", "N", "x"]
11
12export const PUNK_NOSES: string[] = ["└", "╘", "<", "└", "┌", "^", "└", "┼", "Γ"]
13
14export const PUNK_MOUTHS: string[][] = [["   │    │   ", "   │──  │   "], ["   │    │   ", "   │δ   │   "], ["   │    │   ", "   │─┬  │   "], ["   │    │   ", "   │(─) │   "], ["   │    │   ", "   │[─] │   "], ["   │    │   ", "   │<─> │   "], ["   │    │   ", "   │╙─  │   "], ["   │    │   ", "   │─╜  │   "], ["   │    │   ", "   │└─┘ │   "], ["   │    │   ", "   │┌─┐ │   "], ["   │    │   ", "   │╓─  │   "], ["   │    │   ", "   │─╖  │   "], ["   │    │   ", "   │┼─┼ │   "], ["   │    │   ", "   │──┼ │   "], ["   │    │   ", "   │«─» │   "], ["   │    │   ", "   │──  │   "], [" ∙ │    │   ", " ∙───   │   "], [" ∙ │    │   ", " ∙───)  │   "], [" ∙ │    │   ", " ∙───]  │   "], ["   │⌐¬  │   ", " √────  │   "], ["   │╓╖  │   ", "   │──  │   "], ["   │~~  │   ", "   │/\\  │   "], ["   │    │   ", "   │══  │   "], ["   │    │   ", "   │▼▼  │   "], ["   │⌐¬  │   ", "   │O   │   "], ["   │    │   ", "   │O   │   "], [" ∙ │⌐¬  │   ", " ∙───   │   "], [" ∙ │⌐¬  │   ", " ∙───)  │   "], [" ∙ │⌐¬  │   ", " ∙───]  │   "], ["   │⌐¬  │   ", "   │──  │   "], ["   │⌐-¬ │   ", "   │    │   "], ["   │┌-┐ │   ", "   ││ │ │   "]]
15
16export const PUNK_FOREHEAD = "   │    ├┐  "
17export const PUNK_LEFT_FACE = "   │"
18export const PUNK_RIGHT_FACE = " └│  "
19export const PUNK_NOSE_LEFT = "   │ "
20export const PUNK_NOSE_RIGHT = "  └┘  "
21export const PUNK_CHIN: string[] = ["   │    │   ", "   └──┘ │   "]
22export const PUNK_NECK: string[] = ["     │  │   ", "     │  │   "]
23
types/index.d.ts 26 lines
1/**
2 * What `$.store` keeps: the seed is the buddy's whole body, the rest its soul.
3 */
4export type Stored = { seed: string; name: string; personality: string }
5
6/** Derived from the seed on every load, never stored. */
7export type Bones = {
8  seed: string
9  rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
10  eyes: [string, string]
11  /** The contract's parts: a three-line top, an eye pair, a nose, a two-line mouth. */
12  top: string[]
13  nose: string
14  mouth: string[]
15  isShiny: boolean
16  stats: Record<string, number>
17}
18
19export type Pet = Bones & Stored
20
21declare module 'claude-code' {
22  interface PluginState {
23    buddy: { pet: Pet | null; tick: number; says: string | null; isHidden: boolean; isMuted: boolean }
24  }
25}
26