Brings back the April Fools terminal buddy

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.
To set up Juliet, run:
$ git clone --recursive git@github.com:avegancafe/Juliet ~/.config/Juliet
$ ~/.config/Juliet/bin/juliet-bootstrap
Core Binaries:
juliet-bootstrap - Main setup and update scriptbrew bundle to install packages~/.config/Juliet/bin/juliet-bootstrapSystem Utilities:
reindex-spotlight - macOS Spotlight reindexingupdate-kitty - Kitty terminal updaterenvsource - Environment variable sourcingsudo - Enhanced sudo wrapperGit Binaries:
better-branch - Enhanced git branch viewerdelete-current-branch - Safe branch deletion~/.delog for recoverygit-recreate - Branch recreation utilitygit-hoard/ - Git repository organization tool (Node.js project)Git Functions:
g - Short alias for gitcurrent-branch - Get current git branch namecb - Copy current branch name to clipboardcurrent-sha - Get current commit SHAdefault-branch - Get repository's default branchgdiff - Git diff wrappergut - Git status shortcutclone - Enhanced git cloneGit Aliases:
git a - Add files to staginggit ape - Amend previous commit and force push with leasegit b / git bbranch - Show enhanced branch list (uses better-branch)git clean-branches - Delete all branches except master/maingit clean-merged / git cm - Delete branches that have been mergedgit co - Checkout branch/commitgit com - Commit changesgit cp - Cherry-pick commitsgit del - Delete current branch safely (uses delete-current-branch)git diff-branch - Show diff from fork point with mastergit fixup - Create fixup commit for interactive rebasegit fo - Fetch from origingit judge - Enhanced blame with whitespace/move detectiongit main - Switch to main/master branch automaticallygit o / git open - Open repository in GitHubgit pdiff - Pretty diff using bat with syntax highlightinggit pf - Push with force-with-lease (safer force push)git pu - Pull changesgit rb - Rebase commitsgit re - Restore filesgit s - Switch branchesgit squash - Interactive rebase to squash commits against maingit staash - Stash all changes including untracked filesgit staged - Show staged changesgit su - Update submodules recursivelygit t - Ticket (custom command)git up - Push changesEditor Tools:
nv - Launch Neovide GUI editor, auto-installs if missingvim - Alias for nvimcursor - Launch Cursor editorNeovim Management:
nvim-refresh-plugins - Neovim plugin managerEnhanced File Tools:
cat - Enhanced cat using bat with syntax highlightingls - Enhanced ls using eza with better formattingWorkspace Management:
work - Interactive workspace/directory switcher~/.workspaces and ~/.workspaces__local@workspace (directories with subdirs) and @dir (specific dirs) @workspace ~/workspace @dir ~/.config/nvim ``GitHub Integration:
run - GitHub Actions workflow runnerrun <workflow-name> [-c/--current] [-a/--args="args"]gist-to-pdf - GitHub Gist convertergist-to-pdf <gist-id>Container & Cloud Tools:
dc - Docker Compose shortcutkc - kubectl shortcutLanguage-Specific Tools:
node - Node.js wrapperhas-pipfile - Check for Python PipfileSystem Monitoring:
logs - System log viewerwatch - File watching utilitypv - Process viewerst - Status checkerGeneral Utilities:
note - Note-taking utilitysymlinked/ - Contains all dotfiles organized by target locationsymlinked/config/ - Files that go in ~/.config/symlinked/home/ - Files that go in ~/bin/ - Custom utility scripts and toolsetc/ - Assets (fonts, themes, wallpapers, keyboard layouts)Brewfile - Homebrew package definitionsAll 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.
hooks/register.tsx 301 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
301hooks/companion.ts 206 lines1import 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}
206hooks/keccak.ts 61 lines1// 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))
61hooks/punk-parts.ts 23 lines1// 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[] = [" │ │ ", " │ │ "]
23types/index.d.ts 26 lines1/**
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