@ramarivera/coding-buddy 0.4.0-alpha.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/{adapters/claude/plugin → .claude-plugin}/marketplace.json +11 -6
  2. package/{adapters/claude/plugin → .claude-plugin}/plugin.json +7 -3
  3. package/LICENSE +1 -1
  4. package/README.md +88 -37
  5. package/adapters/omp/commands.ts +201 -0
  6. package/adapters/omp/context.ts +22 -0
  7. package/adapters/omp/events.ts +551 -0
  8. package/adapters/omp/identity.ts +10 -0
  9. package/adapters/omp/index.ts +39 -0
  10. package/adapters/omp/logger.ts +52 -0
  11. package/adapters/omp/prompt.ts +60 -0
  12. package/adapters/omp/renderers.ts +73 -0
  13. package/adapters/omp/storage.ts +25 -0
  14. package/adapters/omp/ui.ts +85 -0
  15. package/adapters/pi/commands.ts +64 -37
  16. package/adapters/pi/events.ts +147 -72
  17. package/adapters/pi/index.ts +9 -4
  18. package/adapters/pi/storage.ts +14 -285
  19. package/adapters/pi/ui.ts +56 -5
  20. package/adapters/shared/file-storage.ts +526 -0
  21. package/{adapters/claude/install → cli}/backup.ts +130 -33
  22. package/cli/buddy-shell.ts +1 -1
  23. package/{adapters/claude/install → cli}/disable.ts +19 -32
  24. package/cli/doctor.ts +231 -0
  25. package/{adapters/claude/install → cli}/hunt.ts +7 -9
  26. package/cli/index.ts +30 -26
  27. package/{adapters/claude/install → cli}/install.ts +65 -83
  28. package/{adapters/claude/install → cli}/pick.ts +111 -50
  29. package/{adapters/claude/install → cli}/settings.ts +2 -2
  30. package/{adapters/claude/install → cli}/show.ts +4 -4
  31. package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
  32. package/{adapters/claude/install → cli}/test-statusline.ts +21 -11
  33. package/cli/tui.tsx +106 -17
  34. package/{adapters/claude/install → cli}/uninstall.ts +40 -47
  35. package/cli/upgrade.ts +2 -2
  36. package/cli/validate-species.ts +209 -0
  37. package/cli/verify.ts +3 -3
  38. package/core/achievements.ts +12 -20
  39. package/core/art-data.ts +26 -0
  40. package/core/command-service.ts +99 -97
  41. package/core/engine.ts +83 -1
  42. package/core/ports.ts +10 -0
  43. package/core/reactions.ts +533 -95
  44. package/core/slot-slug.ts +22 -0
  45. package/{adapters/claude/hooks → hooks}/buddy-comment.sh +19 -1
  46. package/hooks/file-type-react.sh +401 -0
  47. package/hooks/hooks.json +52 -0
  48. package/hooks/mood-react.sh +208 -0
  49. package/{adapters/claude/hooks → hooks}/name-react.sh +1 -1
  50. package/hooks/react.sh +1368 -0
  51. package/hooks/suggest.sh +55 -0
  52. package/package.json +70 -31
  53. package/scripts/gen-emoji-widths.ts +34 -0
  54. package/scripts/paths.sh +17 -1
  55. package/scripts/postinstall.cjs +42 -0
  56. package/scripts/snapshot-statusline.sh +54 -0
  57. package/server/achievements.ts +1419 -15
  58. package/server/art.ts +450 -1
  59. package/server/award-xp.ts +48 -0
  60. package/server/check-suggestions.ts +102 -0
  61. package/server/consolidate.ts +33 -0
  62. package/{adapters/claude/server → server}/index.ts +556 -95
  63. package/server/mcp-launcher.sh +16 -6
  64. package/server/memory.ts +404 -0
  65. package/server/mood.ts +254 -0
  66. package/server/path.ts +70 -18
  67. package/server/shift-mood.ts +43 -0
  68. package/server/state.ts +540 -3
  69. package/server/suggestions.ts +335 -0
  70. package/server/theme.ts +53 -0
  71. package/server/xp.ts +435 -0
  72. package/{adapters/claude/skills → skills}/buddy/SKILL.md +36 -7
  73. package/statusline/buddy-status.sh +397 -0
  74. package/statusline/combined-status.sh +134 -0
  75. package/statusline/emoji-widths.data +4 -0
  76. package/adapters/claude/hooks/hooks.json +0 -35
  77. package/adapters/claude/hooks/react.sh +0 -207
  78. package/adapters/claude/install/doctor.ts +0 -172
  79. package/adapters/claude/rendering/art.ts +0 -264
  80. package/adapters/claude/server/instructions.ts +0 -24
  81. package/adapters/claude/server/path.ts +0 -12
  82. package/adapters/claude/server/resources.ts +0 -38
  83. package/adapters/claude/statusline/buddy-status.sh +0 -408
  84. package/adapters/claude/storage/achievements.ts +0 -253
  85. package/adapters/claude/storage/identity.ts +0 -13
  86. package/adapters/claude/storage/paths.ts +0 -33
  87. package/adapters/claude/storage/settings.ts +0 -91
  88. package/adapters/claude/storage/state.ts +0 -365
  89. package/adapters/pi/README.md +0 -83
  90. package/adapters/pi/tools.ts +0 -6
  91. package/server/engine.ts +0 -1
  92. package/server/reactions.ts +0 -1
package/server/art.ts CHANGED
@@ -1 +1,450 @@
1
- export * from "../core/render-model.ts";
1
+ /**
2
+ * ASCII art for all 18 buddy species
3
+ *
4
+ * Each species has 3 animation frames (idle variations).
5
+ * Each frame is 5 lines, ~12 chars wide.
6
+ * {E} is replaced with the eye character at render time.
7
+ */
8
+
9
+ import type { Species, Eye, Hat, Rarity, StatName, BuddyBones } from "../core/engine.ts"
10
+ import { HAT_ART } from "../core/art-data.ts";
11
+ import { getRarityColor } from "./theme.ts";
12
+ export { HAT_ART };
13
+
14
+ // ─── Species art: 3 frames × 5 lines each ──────────────────────────────────
15
+
16
+ export const SPECIES_ART: Record<Species, string[][]> = {
17
+ duck: [
18
+ [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--' "],
19
+ [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--'~ "],
20
+ [" ", " __ ", " <({E} )___ ", " ( .__> ", " `--' "],
21
+ ],
22
+ goose: [
23
+ [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
24
+ [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
25
+ [" ", " ({E}>> ", " || ", " _(__)_ ", " ^^^^ "],
26
+ ],
27
+ blob: [
28
+ [" ", " .----. ", " ( {E} {E} ) ", " ( ) ", " `----' "],
29
+ [" ", " .------. ", " ( {E} {E} ) ", " ( ) ", " `------' "],
30
+ [" ", " .--. ", " ({E} {E}) ", " ( ) ", " `--' "],
31
+ ],
32
+ cat: [
33
+ [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\") "],
34
+ [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\")~ "],
35
+ [" ", " /\\-/\\ ", " ( {E} {E}) ", " ( \u03c9 ) ", " (\")_(\") "],
36
+ ],
37
+ dragon: [
38
+ [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-' "],
39
+ [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ) ", " `-vvvv-' "],
40
+ [" ~ ~ ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-' "],
41
+ ],
42
+ octopus: [
43
+ [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
44
+ [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " \\/\\/\\/\\/ "],
45
+ [" o ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
46
+ ],
47
+ owl: [
48
+ [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " `----' "],
49
+ [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " .----. "],
50
+ [" ", " /\\ /\\ ", " (({E})(-)) ", " ( >< ) ", " `----' "],
51
+ ],
52
+ penguin: [
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
+ ],
72
+ axolotl: [
73
+ [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( .--. ) ", " (_/ \\_) "],
74
+ [" ", "~}(______){~", "~}({E} .. {E}){~", " ( .--. ) ", " (_/ \\_) "],
75
+ [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( -- ) ", " ~_/ \\_~ "],
76
+ ],
77
+ capybara: [
78
+ [" ", " n______n ", " ( {E} {E} ) ", " ( oo ) ", " `------' "],
79
+ [" ", " n______n ", " ( {E} {E} ) ", " ( Oo ) ", " `------' "],
80
+ [" ~ ~ ", " u______n ", " ( {E} {E} ) ", " ( oo ) ", " `------' "],
81
+ ],
82
+ cactus: [
83
+ [" ", " n ____ n ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
84
+ [" ", " ____ ", " n |{E} {E}| n ", " |_| |_| ", " | | "],
85
+ [" n n ", " | ____ | ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
86
+ ],
87
+ robot: [
88
+ [" ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------' "],
89
+ [" ", " .[||]. ", " [ {E} {E} ] ", " [ -==- ] ", " `------' "],
90
+ [" * ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------' "],
91
+ ],
92
+ rabbit: [
93
+ [" ", " (\\__/) ", " ( {E} {E} ) ", " =( .. )= ", " (\")__(\")" ],
94
+ [" ", " (|__/) ", " ( {E} {E} ) ", " =( .. )= ", " (\")__(\")" ],
95
+ [" ", " (\\__/) ", " ( {E} {E} ) ", " =( . . )= ", " (\")__(\")" ],
96
+ ],
97
+ mushroom: [
98
+ [" ", " .-o-OO-o-. ", "(__________)"," |{E} {E}| ", " |____| "],
99
+ [" ", " .-O-oo-O-. ", "(__________)"," |{E} {E}| ", " |____| "],
100
+ [" . o . ", " .-o-OO-o-. ", "(__________)"," |{E} {E}| ", " |____| "],
101
+ ],
102
+ chonk: [
103
+ [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
104
+ [" ", " /\\ /| ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
105
+ [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------'~ "],
106
+ ],
107
+ wyvern: [
108
+ ["} {",
109
+ "|\\^```^/|",
110
+ "\\ {E}' '{E} /",
111
+ " \\ } { /",
112
+ " ≈(° °)≈",
113
+ " '-'"],
114
+ ["} {",
115
+ "|\\^```^/|",
116
+ "\\ {E}' '{E} /",
117
+ " \\ } { /",
118
+ " ≈(° °)≈",
119
+ " \x1b[38;2;255;120;0m//|\\\\\x1b[0m"],
120
+ ["} {",
121
+ "|\\^```^/|",
122
+ "\\ {E}' '{E} /",
123
+ " \\ } { /",
124
+ " ≈(° °)≈",
125
+ " 'v'"],
126
+ ],
127
+ pikachu: [
128
+ [" ", " /\\_/\\ ", " ({E} {E}) ", " ( ω ) ", " (__) "],
129
+ [" ", " /\\_/\\ ", " (- -) ", " ( ω ) ", " (__) "],
130
+ [" ", " /\\_/\\ ", " ({E} {E}) ", " ( ~ ) ", " (__) "],
131
+ ],
132
+ };
133
+
134
+
135
+ // Wyvern line 0 is `} {` (7 inner chars between horns).
136
+ // These replace that line so the hat sits between the horns.
137
+ const WYVERN_HAT: Partial<Record<Hat, string>> = {
138
+ crown: "} \\^^^/ {", // \^^^/ (5) centered in 7
139
+ tophat: "} [___] {", // [___] (5) centered in 7
140
+ propeller: "} -+- {", // -+- (3) centered in 7
141
+ halo: "} ( ) {", // ( ) (5) centered in 7
142
+ wizard: "} /^\\ {", // /^\ (3) centered in 7
143
+ beanie: "} (___) {", // (___) (5) centered in 7
144
+ tinyduck: "} ,> {", // ,> (2) slightly left of center
145
+ };
146
+
147
+ function applyHat(species: Species, hat: Hat, art: string[]): void {
148
+ if (hat === "none") return;
149
+ if (species === "wyvern") {
150
+ const wyvernLine = WYVERN_HAT[hat];
151
+ if (wyvernLine) art[0] = wyvernLine;
152
+ } else if (!art[0].trim()) {
153
+ art[0] = HAT_ART[hat];
154
+ }
155
+ }
156
+
157
+ const SHINY_COLOR = "\x1b[93m"; // bright yellow
158
+ const BOLD = "\x1b[1m";
159
+ const DIM = "\x1b[2m";
160
+ const NC = "\x1b[0m";
161
+
162
+ export const RARITY_STARS: Record<Rarity, string> = {
163
+ common: "\u2605",
164
+ uncommon: "\u2605\u2605",
165
+ rare: "\u2605\u2605\u2605",
166
+ epic: "\u2605\u2605\u2605\u2605",
167
+ legendary: "\u2605\u2605\u2605\u2605\u2605",
168
+ };
169
+
170
+ // ─── Display width helpers ──────────────────────────────────────────────────
171
+
172
+ function stripAnsi(s: string): string { return s.replace(/\x1b\[[^m]*m/g, ""); }
173
+
174
+ // Unicode property escapes (ES2018) are the source of truth for which
175
+ // codepoints terminals render 2 cols wide. The statusline (bash) can't use
176
+ // these directly, so scripts/gen-emoji-widths.ts exports the subset that
177
+ // bash needs into statusline/emoji-widths.data — regenerate on version bumps.
178
+ const EMOJI_PRES_RE = /\p{Emoji_Presentation}/u;
179
+ const EMOJI_RE = /\p{Emoji}/u;
180
+
181
+ // Precondition: ch is neither a variation selector (U+FE00-U+FE0F) nor ZWJ
182
+ // (U+200D); displayWidth filters those before calling in.
183
+ function charWidth(ch: string): number {
184
+ if (EMOJI_PRES_RE.test(ch)) return 2;
185
+ const cp = ch.codePointAt(0)!;
186
+ if (cp >= 0x2500 && cp <= 0x259F) return 1;
187
+ if (cp >= 0x3000 && cp <= 0x9FFF) return 2;
188
+ if (cp >= 0xFF01 && cp <= 0xFF60) return 2;
189
+ return 1;
190
+ }
191
+
192
+ export function displayWidth(s: string): number {
193
+ let w = 0;
194
+ let upgradable = false;
195
+ for (const ch of stripAnsi(s)) {
196
+ const cp = ch.codePointAt(0)!;
197
+ if (cp === 0xFE0F) {
198
+ // VS16 forces emoji presentation on the previous codepoint; upgrade
199
+ // its width from 1 to 2 if it was narrow-but-emoji (e.g. ❤ + VS16).
200
+ if (upgradable) { w += 1; upgradable = false; }
201
+ continue;
202
+ }
203
+ if ((cp >= 0xFE00 && cp <= 0xFE0E) || cp === 0x200D) {
204
+ upgradable = false;
205
+ continue;
206
+ }
207
+ const cw = charWidth(ch);
208
+ w += cw;
209
+ upgradable = cw === 1 && EMOJI_RE.test(ch);
210
+ }
211
+ return w;
212
+ }
213
+
214
+ /** Pad string with spaces to reach target display width */
215
+ function dpad(s: string, targetW: number): string {
216
+ const w = displayWidth(s);
217
+ return w < targetW ? s + " ".repeat(targetW - w) : s;
218
+ }
219
+
220
+ // ─── Render functions ───────────────────────────────────────────────────────
221
+
222
+ export function getArtFrame(species: Species, eye: Eye, frame: number = 0): string[] {
223
+ const frames = SPECIES_ART[species];
224
+ const f = frames[frame % frames.length];
225
+ return f.map((line) => line.replace(/\{E\}/g, eye));
226
+ }
227
+
228
+ // Original 15-tick cycle [0,0,0,0,1,0,0,0,-1,0,0,2,0,0,0]: -1 (blink) becomes
229
+ // index 3 in the pre-baked frames array.
230
+ export const STATUS_FRAME_SEQUENCE: readonly number[] = [
231
+ 0, 0, 0, 0, 1, 0, 0, 0, 3, 0, 0, 2, 0, 0, 0,
232
+ ];
233
+
234
+ // Pre-resolves eye, hat overlay, and blink so the statusline shell does no art
235
+ // work — it just cycles whatever frames the server writes. Each frame is a
236
+ // \n-joined 5-line string (one jq call + mapfile in bash).
237
+ export function getStatusFrames(bones: BuddyBones): {
238
+ frames: string[];
239
+ frameSequence: number[];
240
+ } {
241
+ const resolveFrame = (frameIdx: number, eye: string): string => {
242
+ const raw = SPECIES_ART[bones.species][frameIdx];
243
+ const art = raw.map((line) => line.replace(/\{E\}/g, eye));
244
+ const hatLine = HAT_ART[bones.hat];
245
+ if (hatLine && !art[0].trim()) {
246
+ art[0] = hatLine;
247
+ }
248
+ return art.join("\n");
249
+ };
250
+
251
+ return {
252
+ frames: [
253
+ resolveFrame(0, bones.eye),
254
+ resolveFrame(1, bones.eye),
255
+ resolveFrame(2, bones.eye),
256
+ resolveFrame(0, "-"),
257
+ ],
258
+ frameSequence: [...STATUS_FRAME_SEQUENCE],
259
+ };
260
+ }
261
+
262
+ export function renderCompanionCard(
263
+ bones: BuddyBones,
264
+ name: string,
265
+ personality: string,
266
+ reaction?: string,
267
+ frame: number = 0,
268
+ width: number = 40,
269
+ ): string {
270
+ const color = getRarityColor(bones.rarity);
271
+ const stars = RARITY_STARS[bones.rarity];
272
+ const shiny = bones.shiny ? `${SHINY_COLOR}\u2728 ${NC}` : "";
273
+ const art = getArtFrame(bones.species, bones.eye, frame);
274
+ applyHat(bones.species, bones.hat, art);
275
+
276
+ // Build the card
277
+ const W = Math.max(24, width);
278
+ const hr = "\u2500".repeat(W - 2);
279
+ const lines: string[] = [];
280
+
281
+ // Top border
282
+ lines.push(`${color}\u256d${hr}\u256e${NC}`);
283
+
284
+ // Inner width = W - 2 (borders), content area = W - 4 (borders + padding)
285
+ const innerW = W - 4;
286
+
287
+ // Species art (centered)
288
+ for (const artLine of art) {
289
+ if (!artLine.trim()) continue;
290
+ lines.push(`${color}\u2502${NC} ${dpad(artLine, innerW)}${color}\u2502${NC}`);
291
+ }
292
+
293
+ // Separator
294
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
295
+
296
+ // Name + rarity
297
+ const nameStarsRaw = `${BOLD}${name}${NC} ${color}${stars}${NC}`;
298
+ lines.push(`${color}\u2502${NC} ${nameStarsRaw}${" ".repeat(Math.max(0, innerW - displayWidth(name) - 2 - displayWidth(stars)))}${color}\u2502${NC}`);
299
+
300
+ const rarityRaw = `${shiny}${color}${BOLD}${bones.rarity.toUpperCase()}${NC} ${bones.species}`;
301
+ const rarityVis = (bones.shiny ? 3 : 0) + bones.rarity.length + 1 + bones.species.length;
302
+ lines.push(`${color}\u2502${NC} ${rarityRaw}${" ".repeat(Math.max(0, innerW - rarityVis))}${color}\u2502${NC}`);
303
+
304
+ // Eye + Hat info
305
+ const cosmeticLine = `eye: ${bones.eye} hat: ${bones.hat}`;
306
+ lines.push(`${color}\u2502${NC} ${DIM}${cosmeticLine}${NC}${" ".repeat(Math.max(0, innerW - displayWidth(cosmeticLine)))}${color}\u2502${NC}`);
307
+
308
+ // Separator
309
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
310
+
311
+ // Stats
312
+ const STAT_NAMES: StatName[] = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
313
+ for (const stat of STAT_NAMES) {
314
+ const val = bones.stats[stat];
315
+ const filled = Math.round(val / 10);
316
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
317
+ const label = stat.slice(0, 3).padEnd(3);
318
+ const marker = stat === bones.peak ? " \u25b2" : stat === bones.dump ? " \u25bc" : " ";
319
+ const valStr = String(val).padStart(3);
320
+ const statLine = `${DIM}${label}${NC} ${bar} ${valStr}${marker}`;
321
+ const statVis = 3 + 1 + 10 + 1 + 3 + 2; // label + spaces + bar + val + marker = 20
322
+ lines.push(`${color}\u2502${NC} ${statLine}${" ".repeat(Math.max(0, innerW - statVis))}${color}\u2502${NC}`);
323
+ }
324
+
325
+ // Speech bubble (if reaction)
326
+ if (reaction) {
327
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
328
+ const maxMsg = innerW - 3; // "💬 " prefix (💬 = 2 cols + space)
329
+ const msg = displayWidth(reaction) > maxMsg ? reaction.slice(0, maxMsg - 1) + "\u2026" : reaction;
330
+ const msgPad = Math.max(0, innerW - displayWidth(msg) - 3);
331
+ lines.push(`${color}\u2502${NC} \ud83d\udcac ${msg}${" ".repeat(msgPad)}${color}\u2502${NC}`);
332
+ }
333
+
334
+ // Personality
335
+ if (personality) {
336
+ lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
337
+ const words = personality.split(" ");
338
+ let line = "";
339
+ for (const word of words) {
340
+ if (displayWidth(line) + displayWidth(word) + 1 > innerW) {
341
+ lines.push(`${color}\u2502${NC} ${DIM}${dpad(line, innerW)}${NC}${color}\u2502${NC}`);
342
+ line = word;
343
+ } else {
344
+ line = line ? `${line} ${word}` : word;
345
+ }
346
+ }
347
+ if (line) {
348
+ lines.push(`${color}\u2502${NC} ${DIM}${dpad(line, innerW)}${NC}${color}\u2502${NC}`);
349
+ }
350
+ }
351
+
352
+ // Bottom border
353
+ lines.push(`${color}\u2570${hr}\u256f${NC}`);
354
+
355
+ return lines.join("\n");
356
+ }
357
+
358
+ // ─── Markdown-native render (for MCP tool responses) ───────────────────────
359
+ //
360
+ // Claude Code's UI doesn't render raw ANSI escape codes properly — it strips
361
+ // the ESC byte but leaves "[38;2;...m" as literal text, making the output
362
+ // unreadable. This renderer produces pure markdown with unicode rarity dots
363
+ // instead of ANSI colors, so it renders cleanly in any MCP client UI.
364
+
365
+ const RARITY_DOT: Record<Rarity, string> = {
366
+ common: "\u26AA", // ⚪ white circle
367
+ uncommon: "\uD83D\uDFE2", // 🟢 green circle
368
+ rare: "\uD83D\uDD35", // 🔵 blue circle
369
+ epic: "\uD83D\uDFE3", // 🟣 purple circle
370
+ legendary: "\uD83D\uDFE1", // 🟡 yellow circle
371
+ };
372
+
373
+ export function renderCompanionCardMarkdown(
374
+ bones: BuddyBones,
375
+ name: string,
376
+ personality: string,
377
+ reaction?: string,
378
+ frame: number = 0,
379
+ ): string {
380
+ const dot = RARITY_DOT[bones.rarity];
381
+ const stars = RARITY_STARS[bones.rarity];
382
+ const shiny = bones.shiny ? " \u2728" : "";
383
+ const art = getArtFrame(bones.species, bones.eye, frame);
384
+ applyHat(bones.species, bones.hat, art);
385
+
386
+ // Strip empty lines from art for cleaner rendering
387
+ const artLines = art.filter((l) => l.trim().length > 0);
388
+
389
+ const STAT_NAMES: StatName[] = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
390
+ const statRows = STAT_NAMES.map((stat) => {
391
+ const val = bones.stats[stat];
392
+ const filled = Math.round(val / 10);
393
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
394
+ const marker = stat === bones.peak ? " \u25B2" : stat === bones.dump ? " \u25BC" : "";
395
+ const label = `**${stat.slice(0, 3)}**${stat.slice(3)}`;
396
+ return `| ${label} | ${val}${marker} | \`${bar}\` |`;
397
+ }).join("\n");
398
+
399
+ const parts: string[] = [];
400
+
401
+ // Header: rarity dot, name, species+rarity, stars, shiny
402
+ parts.push(`### ${dot} ${name} · \`${bones.rarity.toUpperCase()} ${bones.species}\` · ${stars}${shiny}`);
403
+ parts.push("");
404
+
405
+ // ASCII art in a code block (preserves monospaced formatting)
406
+ parts.push("```");
407
+ parts.push(artLines.join("\n"));
408
+ parts.push("```");
409
+ parts.push("");
410
+
411
+ // Identity line
412
+ parts.push(`**Identity:** eye \`${bones.eye}\` · hat \`${bones.hat}\``);
413
+ parts.push("");
414
+
415
+ // Stats table
416
+ parts.push("| Stat | Value | Bar |");
417
+ parts.push("|---|---|---|");
418
+ parts.push(statRows);
419
+ parts.push("");
420
+
421
+ // Reaction (if any) — reactions often already contain asterisks
422
+ // for actions like "*blinks slowly*", so render them verbatim to avoid
423
+ // accidentally turning italics into bold.
424
+ if (reaction) {
425
+ parts.push(`\ud83d\udcac ${reaction}`);
426
+ parts.push("");
427
+ }
428
+
429
+ // Personality as blockquote
430
+ if (personality) {
431
+ parts.push(`> ${personality}`);
432
+ }
433
+
434
+ return parts.join("\n");
435
+ }
436
+
437
+ // ─── Compact status line render ─────────────────────────────────────────────
438
+
439
+ export function renderStatusLine(
440
+ bones: BuddyBones,
441
+ name: string,
442
+ reaction?: string,
443
+ ): string {
444
+ const face = SPECIES_ART[bones.species][0][2]?.replace(/\{E\}/g, bones.eye).trim() || "(?)";
445
+ const color = getRarityColor(bones.rarity);
446
+ const stars = RARITY_STARS[bones.rarity];
447
+ const shiny = bones.shiny ? "\u2728" : "";
448
+ const msg = reaction ? ` \u2502 "${reaction}"` : "";
449
+ return `${color}${face}${NC} ${BOLD}${name}${NC} ${shiny}${color}${stars}${NC}${msg}`;
450
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Lightweight XP awarding script — called from shell hooks.
4
+ * Awards XP for coding events without loading the full MCP server.
5
+ *
6
+ * Usage:
7
+ * bun run server/award-xp.ts <event> [slot]
8
+ *
9
+ * Events: errors_spotted | tests_passed | tests_failed | large_diff | turn | time_spent | buddy_pet
10
+ */
11
+
12
+ import { awardXp } from "./xp";
13
+ import { loadCompanionSlot, loadActiveSlot } from "./state";
14
+ import type { XpEvent } from "./xp";
15
+
16
+ const VALID_EVENTS = new Set([
17
+ "errors_spotted",
18
+ "tests_passed",
19
+ "tests_failed",
20
+ "large_diff",
21
+ "turn",
22
+ "time_spent",
23
+ "buddy_pet",
24
+ ]);
25
+
26
+ function main(): void {
27
+ const event = process.argv[2] as string;
28
+ const slot = process.argv[3] ?? loadActiveSlot();
29
+
30
+ if (!event || !VALID_EVENTS.has(event)) {
31
+ console.error(
32
+ `Usage: bun run server/award-xp.ts <event> [slot]\nValid events: ${[...VALID_EVENTS].join(" | ")}`,
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ // Get species and rarity for bonus calculation
38
+ const companion = loadCompanionSlot(slot);
39
+ const species = companion?.bones.species;
40
+ const rarity = companion?.bones.rarity;
41
+
42
+ const state = awardXp(event as XpEvent, slot, species, rarity);
43
+ console.log(
44
+ `XP awarded: +${event} → Level ${state.level} (${state.totalXp.toLocaleString()} XP total)`,
45
+ );
46
+ }
47
+
48
+ main();
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Check for suggestion patterns in the assistant's message.
4
+ * Called from suggest.sh hook.
5
+ *
6
+ * Usage:
7
+ * bun run server/check-suggestions.ts <assistant_message>
8
+ */
9
+
10
+ import { join } from "path";
11
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
12
+ import { buddyStateDir } from "./path";
13
+ import {
14
+ checkRepeatedError,
15
+ checkLargeDiff,
16
+ checkTodoComment,
17
+ checkLongFunction,
18
+ checkNoTestsLongSession,
19
+ recordTurn,
20
+ recordSuggestion,
21
+ isOnCooldown,
22
+ type Suggestion,
23
+ } from "./suggestions";
24
+
25
+ const PENDING_FILE = join(buddyStateDir(), ".pending_suggestion.json");
26
+
27
+ function main(): void {
28
+ const message = process.argv[2] ?? "";
29
+ if (!message) {
30
+ // When called with no args, just record a turn
31
+ recordTurn();
32
+ return;
33
+ }
34
+
35
+ // Parse the JSON string (jq -Rs wraps in quotes and escapes)
36
+ const text = message.replace(/^"/, "").replace(/"$/, "").replace(/\\"/g, '"');
37
+
38
+ // Record turn happened
39
+ recordTurn();
40
+
41
+ // Check cooldown
42
+ if (isOnCooldown(180)) return;
43
+
44
+ let suggestion: Suggestion | null = null;
45
+
46
+ // Extract error messages
47
+ const errorPatterns = [
48
+ /([A-Z][a-z]+Error: .+)/,
49
+ /(SyntaxError: .+)/,
50
+ /(TypeError: .+)/,
51
+ /(ReferenceError: .+)/,
52
+ /(Error: .+)/,
53
+ /(panic: .+)/,
54
+ ];
55
+
56
+ for (const pattern of errorPatterns) {
57
+ const match = text.match(pattern);
58
+ if (match) {
59
+ suggestion = checkRepeatedError(match[0]);
60
+ if (suggestion) break;
61
+ }
62
+ }
63
+
64
+ // Check for large diffs (look for insertion counts)
65
+ if (!suggestion) {
66
+ const diffMatch = text.match(/(\d+)\s+insertions/);
67
+ if (diffMatch) {
68
+ const lines = parseInt(diffMatch[1], 10);
69
+ if (lines > 50) {
70
+ suggestion = checkLargeDiff(lines);
71
+ }
72
+ }
73
+ }
74
+
75
+ // Check for TODO/FIXME
76
+ if (!suggestion) {
77
+ suggestion = checkTodoComment(text);
78
+ }
79
+
80
+ // Check for long functions
81
+ if (!suggestion) {
82
+ suggestion = checkLongFunction(text);
83
+ }
84
+
85
+ // Check for no-tests-long-session
86
+ if (!suggestion) {
87
+ suggestion = checkNoTestsLongSession();
88
+ }
89
+
90
+ if (suggestion) {
91
+ recordSuggestion();
92
+ // Write suggestion to file for MCP server to pick up
93
+ mkdirSync(buddyStateDir(), { recursive: true });
94
+ writeFileSync(
95
+ PENDING_FILE,
96
+ JSON.stringify({ suggestion, timestamp: Date.now() }),
97
+ );
98
+ console.log(`Suggestion: ${suggestion.message}`);
99
+ }
100
+ }
101
+
102
+ main();
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Lightweight memory consolidation script — called from Stop hook.
4
+ * Extracts project, bug, and preference signals from the conversation.
5
+ *
6
+ * Usage:
7
+ * bun run server/consolidate.ts <assistant_message> <user_prompt>
8
+ *
9
+ * Both arguments should be the raw text of the messages.
10
+ */
11
+
12
+ import { consolidateMemory } from "./memory";
13
+
14
+ function main(): void {
15
+ const assistantMessage = process.argv[2] ?? "";
16
+ const userPrompt = process.argv[3] ?? "";
17
+
18
+ if (!assistantMessage && !userPrompt) {
19
+ console.error("Usage: bun run server/consolidate.ts <assistant_message> <user_prompt>");
20
+ process.exit(1);
21
+ }
22
+
23
+ try {
24
+ consolidateMemory(assistantMessage, userPrompt);
25
+ console.log("Memory consolidated successfully.");
26
+ } catch (e) {
27
+ // Non-fatal — memory consolidation should never crash the hook
28
+ console.error("Memory consolidation failed:", e);
29
+ process.exit(0); // still exit 0 — don't break the hook
30
+ }
31
+ }
32
+
33
+ main();