Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ claude-buddy/
| `/buddy position [top\|left]` | Bubble position (tmux only) |
| `/buddy rarity [on\|off]` | Show or hide stars + rarity line (tmux only) |
| `/buddy width [10-60]` | Set bubble text width in chars (tmux only) |
| `/buddy margin [0-20]` | Set right-side margin (tmux only) |
| `/buddy margin [0-20]` | Set distance from terminal right edge to buddy art |
| `/buddy bars_offset [0-40]` | Set distance from terminal left edge to usage bars (combined mode) |
| `/buddy statusline [on\|off]` | Enable or disable buddy in the status line |
| `/buddy statusline combined` | Show rate-limit usage bars alongside buddy (needs python3) |
| `/buddy statusline basic` | Switch back to buddy-only status line |
Expand Down
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 86 additions & 44 deletions cli/pick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import {
loadCompanionSlot, saveCompanionSlot, slugify, unusedName, writeStatusState,
} from "../server/state.ts";
import {
generateBones, generatePersonality, SPECIES, RARITIES, STAT_NAMES, RARITY_STARS, EYES, HATS,
generateBones, SPECIES, RARITIES, STAT_NAMES, RARITY_STARS, EYES, HATS,
type Species, type Rarity, type StatName, type Eye, type Hat,
type BuddyBones, type Companion,
} from "../server/engine.ts";
import { generateBuddy } from "../server/generation.ts";
import { renderCompanionCard } from "../server/art.ts";
import { randomBytes } from "crypto";

Expand Down Expand Up @@ -113,37 +114,43 @@ interface SlotEntry { slot: string; companion: Companion; }
interface BuddyResult { userId: string; bones: BuddyBones; }

interface State {
mode: Mode;
searching: boolean;
savedSlots: SlotEntry[];
savedCursor: number;
activeSlot: string;
criteriaFocus: number;
ci: number[]; // [speciesIdx, rarityIdx, shinyIdx, peakIdx, dumpIdx, eyeIdx, hatIdx, avgIdx, dbgIdx, patIdx, chaIdx, wisIdx, snkIdx]
results: BuddyResult[];
resultCursor: number;
searchStatus: string;
nameInput: string;
pendingResult: BuddyResult | null;
message: string;
mode: Mode;
searching: boolean;
savedSlots: SlotEntry[];
savedCursor: number;
activeSlot: string;
criteriaFocus: number;
ci: number[]; // [speciesIdx, rarityIdx, shinyIdx, peakIdx, dumpIdx, eyeIdx, hatIdx, avgIdx, dbgIdx, patIdx, chaIdx, wisIdx, snkIdx]
results: BuddyResult[];
resultCursor: number;
searchStatus: string;
nameInput: string;
pendingResult: BuddyResult | null;
pendingGen: { name: string; personality: string } | null;
pendingGenLoading: boolean;
message: string;
spinnerTick: number;
}

function fresh(): State {
return {
mode: "saved",
searching: false,
savedSlots: listCompanionSlots(),
savedCursor: 0,
activeSlot: loadActiveSlot(),
criteriaFocus: 0,
mode: "saved",
searching: false,
savedSlots: listCompanionSlots(),
savedCursor: 0,
activeSlot: loadActiveSlot(),
criteriaFocus: 0,
// Default criteria: legendary, any species/shiny/peak/dump/eye/hat/avg/stats
ci: [0, RA_OPTS.indexOf("legendary"), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
results: [],
resultCursor: 0,
searchStatus: "",
nameInput: "",
pendingResult: null,
message: "",
results: [],
resultCursor: 0,
searchStatus: "",
nameInput: "",
pendingResult: null,
pendingGen: null,
pendingGenLoading: false,
message: "",
spinnerTick: 0,
};
}

Expand Down Expand Up @@ -249,29 +256,32 @@ function namingPane(s: State): string[] {
if (b) lines.push(` ${clr}${b.rarity} ${b.species}${N}`);
lines.push(GR + " " + "─".repeat(LEFT_W - 2) + N);
lines.push(` ${B}Name:${N} ${s.nameInput}${YL}▌${N}`);
lines.push(` ${GR}(type a name, or enter for random)${N}`);
if (s.pendingGenLoading) {
const frame = SPINNER_FRAMES[s.spinnerTick % SPINNER_FRAMES.length];
lines.push(` ${GR}${frame} generating suggestion...${N}`);
} else {
lines.push(` ${GR}(enter to save, or type a different name)${N}`);
}
return lines;
}

const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

function previewPane(s: State): string[] {
let c: Companion | null = null;

if (s.mode === "saved") {
c = s.savedSlots[s.savedCursor]?.companion ?? null;
} else if (s.mode === "results") {
const r = s.results[s.resultCursor];
if (r) c = {
bones: r.bones, name: "???",
personality: generatePersonality(r.bones, r.userId),
hatchedAt: Date.now(), userId: r.userId,
};
if (r) {
c = { bones: r.bones, name: "???", personality: "", hatchedAt: Date.now(), userId: r.userId };
}
} else if (s.mode === "naming" && s.pendingResult) {
const r = s.pendingResult;
c = {
bones: r.bones, name: s.nameInput || "???",
personality: generatePersonality(r.bones, r.userId),
hatchedAt: Date.now(), userId: r.userId,
};
const displayName = s.nameInput || s.pendingGen?.name || "???";
const displayPersonality = s.pendingGen?.personality ?? "";
c = { bones: r.bones, name: displayName, personality: displayPersonality, hatchedAt: Date.now(), userId: r.userId };
}

if (!c) return [` ${GR}no preview${N}`];
Expand Down Expand Up @@ -399,6 +409,28 @@ async function runSearch(s: State): Promise<void> {
drawScreen(s);
}

// ─── Naming-mode generation ───────────────────────────────────────────────────

async function startNamingGeneration(r: BuddyResult, s: State, redraw: () => void): Promise<void> {
if (s.pendingGenLoading || s.pendingGen) return;
s.pendingGenLoading = true;

const spinnerInterval = setInterval(() => {
s.spinnerTick++;
redraw();
}, 100);

try {
const { name, personality } = await generateBuddy(r.bones, r.userId);
s.pendingGen = { name, personality };
if (!s.nameInput) s.nameInput = name; // auto-fill if user hasn't typed yet
} finally {
s.pendingGenLoading = false;
clearInterval(spinnerInterval);
redraw();
}
}

// ─── Key handlers ─────────────────────────────────────────────────────────────

function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); }
Expand All @@ -411,19 +443,20 @@ function onKey(key: string, s: State): boolean {
case "naming": {
if (key === "\x1b") {
s.mode = "results"; s.nameInput = ""; s.pendingResult = null;
s.pendingGen = null; s.pendingGenLoading = false;
} else if (key === "\r" || key === "\n") {
// Empty input → auto-pick a random unused name
const name = s.nameInput.trim() || unusedName();
const r = s.pendingResult!;
// Use typed name, or LLM suggestion, or random fallback
const name = s.nameInput.trim() || s.pendingGen?.name || unusedName();
const slot = slugify(name);
if (loadCompanionSlot(slot)) {
s.message = `"${slot}" already taken — type a different name`;
s.nameInput = "";
break;
}
const r = s.pendingResult!;
const companion: Companion = {
bones: r.bones, name,
personality: generatePersonality(r.bones, r.userId),
personality: s.pendingGen?.personality ?? "This creature doesn't want to tell you about itself right now.",
hatchedAt: Date.now(), userId: r.userId,
};
saveCompanionSlot(companion, slot);
Expand Down Expand Up @@ -500,9 +533,11 @@ function onKey(key: string, s: State): boolean {
else if (key === "\r" || key === "\n") {
const r = s.results[s.resultCursor];
if (r) {
s.pendingResult = r;
s.nameInput = ""; // empty — user types name or presses Enter for auto
s.mode = "naming";
s.pendingResult = r;
s.nameInput = "";
s.pendingGen = null;
s.pendingGenLoading = false;
s.mode = "naming";
}
}
break;
Expand Down Expand Up @@ -536,10 +571,17 @@ async function main(): Promise<void> {
const s = fresh();
drawScreen(s);

function redraw() { drawScreen(s); }

await new Promise<void>((resolve) => {
process.stdin.on("data", (key: string) => {
const prevMode = s.mode;
const quit = onKey(key, s);
drawScreen(s);
// Fire generation when entering naming mode
if (s.mode === "naming" && prevMode !== "naming" && s.pendingResult) {
startNamingGeneration(s.pendingResult, s, redraw);
}
if (quit) {
cleanup();
process.stdout.write("\x1b[2J\x1b[H");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# LLM-Based Personality & Name Generation

**Date:** 2026-04-21
**Status:** Approved

## Problem

Creature personalities and names are currently generated from fixed template pools ("A {rarity} {species} that {peak_phrase}. {rarity_closer} Though it {dump_phrase}.") with 15 peak phrases and 15 dump phrases. The results feel formulaic and repetitive across creatures. Names are random from a fixed word list.

## Goal

Replace template-based personality and name generation with LLM-generated text that feels unique to each creature's stats, species, and rarity. Generation should be invisible to the user — results are ready by the time they confirm hatching.

## Architecture

### New module: `server/generation.ts`

Exports two async functions:

```typescript
generatePersonality(bones: BuddyBones): Promise<string>
generateName(bones: BuddyBones, personality: string): Promise<string>
```

Both use `claude-haiku-4-5` via the Anthropic SDK (new dependency: `@anthropic-ai/sdk`).

**`generatePersonality` prompt inputs:**
- Species, rarity, shiny status
- All 5 stat values (DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK)
- Peak stat and dump stat
- Instruction: 3-4 sentences, trading-card/creature-compendium flavor text style, no meta-commentary, in-world description only

**`generateName` prompt inputs:**
- Species, rarity
- The already-generated personality (for coherence)
- Instruction: 1-2 words, gender-neutral, evocative

Name is generated after personality so it can reflect the character.

**Removals:**
- Template-based `generatePersonality` in `engine.ts`
- `generatePersonalityPrompt` stub in `reactions.ts`

### Picker TUI: `cli/pick.ts`

When a creature is highlighted in the picker list:

1. Fire `generatePersonality(bones)` immediately
2. On resolve, fire `generateName(bones, personality)`
3. Right pane shows a spinner in place of name and personality text while generating
4. On resolve, right pane updates with live results

**Caching:** Results are stored in a local `Map<string, {name, personality}>` keyed by a hash of the creature's bones. Scrolling back to a previously-viewed creature uses the cached result — no second API call.

**At hatch:** The already-generated name and personality are written directly into the new `Companion`. No second generation call.

### New MCP tools: `server/index.ts`

| Tool | Description |
|------|-------------|
| `buddy_generate_personality` | Re-generates personality for the active companion via LLM. Updates stored personality and returns new text. |
| `buddy_generate_name` | Re-generates name for the active companion via LLM. Updates stored name and returns new name. |

Existing `buddy_set_personality` and `buddy_rename` are unchanged — they remain the manual override path.

## Error Handling

On API failure (any error), both functions return a humorous placeholder:

- **Name:** `TryAgainLater`
- **Personality:** `This creature doesn't want to tell you about itself right now.`

For re-roll tools (`buddy_generate_personality`, `buddy_generate_name`): failures return a clear user-facing error message ("Generation failed — try again in a moment").

## Dependencies

- Add `@anthropic-ai/sdk` to `package.json`
- API key sourced from `ANTHROPIC_API_KEY` environment variable (always present in Claude Code sessions)

## Out of Scope

- Streaming personality text into the picker pane (plain resolved string is sufficient)
- Regenerating personality/name on stat changes
- Batch pre-generation of personalities for the full picker list
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"homepage": "https://github.com/1270011/claude-buddy#readme",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@xterm/addon-serialize": "^0.14.0",
Expand Down
10 changes: 7 additions & 3 deletions server/art.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,16 @@ export const SPECIES_ART: Record<Species, string[][]> = {
]
};

// ─── ANSI color constants ────────────────────────────────────────────────────

const GOLD = "\x1b[38;2;200;160;0m";
const NC = "\x1b[0m";

// ─── Hat art ────────────────────────────────────────────────────────────────

export const HAT_ART: Record<Hat, string> = {
none: "",
crown: " \\^^^/ ",
crown: ` ${GOLD}\\^^^/${NC} `,
tophat: " [___] ",
propeller: " -+- ",
halo: " ( ) ",
Expand All @@ -139,7 +144,7 @@ export const HAT_ART: Record<Hat, string> = {
// Wyvern line 0 is `} {` (7 inner chars between horns).
// These replace that line so the hat sits between the horns.
const WYVERN_HAT: Partial<Record<Hat, string>> = {
crown: "} \\^^^/ {", // \^^^/ (5) centered in 7
crown: `} ${GOLD}\\^^^/${NC} {`, // \^^^/ (5) centered in 7
tophat: "} [___] {", // [___] (5) centered in 7
propeller: "} -+- {", // -+- (3) centered in 7
halo: "} ( ) {", // ( ) (5) centered in 7
Expand Down Expand Up @@ -171,7 +176,6 @@ const RARITY_COLOR: Record<Rarity, string> = {
const SHINY_COLOR = "\x1b[93m"; // bright yellow
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
const NC = "\x1b[0m";

export const RARITY_STARS: Record<Rarity, string> = {
common: "\u2605",
Expand Down
Loading