@ramarivera/coding-buddy 0.8.0 → 0.9.1

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 (56) hide show
  1. package/README.md +23 -4
  2. package/adapters/omp/context.ts +1 -1
  3. package/adapters/omp/events.ts +286 -81
  4. package/adapters/omp/index.ts +3 -1
  5. package/adapters/omp/renderers.ts +4 -54
  6. package/adapters/omp/storage.ts +6 -1
  7. package/adapters/omp/ui.ts +17 -6
  8. package/adapters/pi/events.ts +280 -83
  9. package/adapters/pi/index.ts +11 -2
  10. package/adapters/pi/renderers.ts +3 -47
  11. package/adapters/pi/storage.ts +6 -1
  12. package/adapters/pi/ui.ts +17 -6
  13. package/adapters/shared/file-storage.ts +22 -0
  14. package/adapters/shared/widget-layout.ts +248 -2
  15. package/cli/settings.ts +1 -1
  16. package/cli/tui.tsx +1 -1
  17. package/core/command-service.ts +13 -0
  18. package/core/engine.ts +11 -2
  19. package/core/identity.ts +15 -0
  20. package/core/model.ts +3 -0
  21. package/core/ports.ts +4 -0
  22. package/core/reactions.ts +29 -2
  23. package/core/render-model.ts +4 -3
  24. package/package.json +6 -3
  25. package/scripts/ci/Dockerfile.vhs +125 -0
  26. package/scripts/ci/check-statusline.sh +441 -14
  27. package/scripts/ci/check-vhs-goldens.sh +97 -0
  28. package/scripts/ci/fonts.conf +37 -0
  29. package/scripts/ci/goldens/baseline-40.png +0 -0
  30. package/scripts/ci/goldens/baseline-80.png +0 -0
  31. package/scripts/ci/goldens/cjk-80.png +0 -0
  32. package/scripts/ci/goldens/emoji-80.png +0 -0
  33. package/scripts/ci/render-statusline-pty.py +107 -0
  34. package/scripts/ci/render-vhs-goldens.sh +226 -0
  35. package/scripts/ci/statusline.tape +28 -2
  36. package/scripts/ci/vhs-density-fixtures.sh +27 -0
  37. package/scripts/ci/vhs-run-statusline.sh +28 -0
  38. package/scripts/ci/write-density-status.ts +86 -0
  39. package/scripts/gen-emoji-widths.ts +41 -17
  40. package/scripts/paths.sh +3 -1
  41. package/server/art.ts +70 -17
  42. package/server/hooks/buddy-comment.ts +166 -17
  43. package/server/hooks/file-type-react.ts +1 -1
  44. package/server/hooks/mood-react.ts +1 -1
  45. package/server/hooks/name-react.ts +1 -1
  46. package/server/hooks/react.ts +1 -1
  47. package/server/hooks/reaction-data.ts +10 -3
  48. package/server/index.ts +67 -27
  49. package/server/path.ts +3 -0
  50. package/server/state.ts +147 -41
  51. package/skills/buddy/SKILL.md +1 -1
  52. package/statusline/buddy-status.sh +488 -76
  53. package/statusline/emoji-text.data +3 -0
  54. package/statusline/emoji-widths.data +2 -3
  55. package/statusline/substatus.sh +52 -10
  56. package/scripts/postinstall.cjs +0 -42
@@ -11,6 +11,7 @@ import {
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
13
  import { join } from "node:path";
14
+ import { configuredSharedUserId } from "../../core/identity.ts";
14
15
  import {
15
16
  EMPTY_GLOBAL,
16
17
  EMPTY_SLOT,
@@ -55,7 +56,9 @@ export const DEFAULT_FILE_BUDDY_CONFIG: FileBuddyConfig = {
55
56
  bubblePosition: "top",
56
57
  showRarity: true,
57
58
  statusLineEnabled: true,
59
+ statuslineDensity: "auto",
58
60
  turnCommentModel: undefined,
61
+ turnCommentTimeoutMs: undefined,
59
62
  muted: false,
60
63
  };
61
64
 
@@ -312,6 +315,22 @@ export class FileBuddyStorage
312
315
  });
313
316
  }
314
317
 
318
+ updateSlot(
319
+ slot: string,
320
+ transform: (companion: Companion) => Companion | null,
321
+ ): Companion | null {
322
+ return this.withLock(() => {
323
+ const manifest = this.loadManifest();
324
+ const existing = manifest.companions[slot];
325
+ if (!existing) return null;
326
+ const updated = transform(existing);
327
+ if (!updated) return null;
328
+ manifest.companions[slot] = updated;
329
+ this.saveManifest(manifest);
330
+ return updated;
331
+ });
332
+ }
333
+
315
334
  loadSlot(slot: string): Companion | null {
316
335
  return this.loadManifest().companions[slot] ?? null;
317
336
  }
@@ -505,6 +524,9 @@ export class FileBuddyStorage
505
524
  }
506
525
 
507
526
  ensureStableIdentity(): string {
527
+ const sharedUserId = configuredSharedUserId();
528
+ if (sharedUserId) return sharedUserId;
529
+
508
530
  return this.withLock(() => {
509
531
  this.ensureDir();
510
532
  try {
@@ -1,6 +1,16 @@
1
+ import { RARITY_STARS } from "../../core/engine.ts";
2
+ import type { Achievement } from "../../core/achievements.ts";
3
+ import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
4
+ import type { Companion, ReactionState } from "../../core/model.ts";
5
+ import { getRarityColor } from "../../server/theme.ts";
6
+
1
7
  const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
2
8
  const ART_GAP = " ";
3
9
  const DEFAULT_WIDGET_WIDTH = 78;
10
+ const RESET = "\x1b[0m";
11
+ const DIM_ITALIC = "\x1b[2;3m";
12
+ const resizeSubscribers = new Set<() => void>();
13
+ let resizeDispatcher: (() => void) | null = null;
4
14
 
5
15
  export const WIDGET_MAX_LINES = 10;
6
16
 
@@ -8,10 +18,33 @@ export function stripAnsi(text: string): string {
8
18
  return text.replace(ANSI_PATTERN, "");
9
19
  }
10
20
 
21
+ // East-Asian Wide/Fullwidth ranges. These MUST stay in sync with char_width()
22
+ // in statusline/buddy-status.sh \u2014 the two surfaces render the same companion
23
+ // card, and a disagreement here shears the bubble: borders sized from an
24
+ // undercounted width, text rows drawn at their true width.
25
+ //
26
+ // Box Drawing (U+2500-U+257F) and Block Elements sit inside the CJK span but
27
+ // are narrow, so they are carved out explicitly, matching the shell.
28
+ const BOX_DRAWING_START = 0x2500; // 9472
29
+ const BOX_DRAWING_END = 0x25bf; // 9631
30
+ const CJK_START = 0x3000; // 12288 \u2014 CJK punctuation through Hangul syllables
31
+ const CJK_END = 0x9fff; // 40959
32
+ const FULLWIDTH_START = 0xff01; // 65281 \u2014 Fullwidth ASCII variants
33
+ const FULLWIDTH_END = 0xff60; // 65376
34
+
35
+ function isEastAsianWide(codePoint: number): boolean {
36
+ if (codePoint >= BOX_DRAWING_START && codePoint <= BOX_DRAWING_END) return false;
37
+ if (codePoint >= CJK_START && codePoint <= CJK_END) return true;
38
+ if (codePoint >= FULLWIDTH_START && codePoint <= FULLWIDTH_END) return true;
39
+ return false;
40
+ }
41
+
11
42
  function characterWidth(character: string, next?: string): number {
12
43
  if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
13
44
  if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
14
- return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
45
+ if (/\p{Emoji_Presentation}/u.test(character)) return 2;
46
+ const codePoint = character.codePointAt(0);
47
+ return codePoint !== undefined && isEastAsianWide(codePoint) ? 2 : 1;
15
48
  }
16
49
 
17
50
  export function displayWidth(text: string): number {
@@ -69,7 +102,26 @@ export function wrapReaction(reaction: string, width: number, maxLines: number):
69
102
 
70
103
  export function getWidgetWidth(): number {
71
104
  const columns = process.stdout.columns;
72
- return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
105
+ return columns && columns > 0 ? Math.max(1, columns - 2) : DEFAULT_WIDGET_WIDTH;
106
+ }
107
+
108
+ /** Share one host resize listener across all adapter UI instances. */
109
+ export function subscribeToWidgetResize(callback: () => void): () => void {
110
+ resizeSubscribers.add(callback);
111
+ if (!resizeDispatcher) {
112
+ resizeDispatcher = () => {
113
+ for (const subscriber of resizeSubscribers) subscriber();
114
+ };
115
+ process.stdout.on("resize", resizeDispatcher);
116
+ }
117
+
118
+ return () => {
119
+ resizeSubscribers.delete(callback);
120
+ if (resizeSubscribers.size === 0 && resizeDispatcher) {
121
+ process.stdout.removeListener("resize", resizeDispatcher);
122
+ resizeDispatcher = null;
123
+ }
124
+ };
73
125
  }
74
126
 
75
127
  export function getDetailsWidth(art: string[], width: number = getWidgetWidth()): number {
@@ -94,3 +146,197 @@ export function composeDetailsAndArt(
94
146
  return `${boundedDetailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(boundedDetailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
95
147
  });
96
148
  }
149
+
150
+ function trimArt(line: string): string {
151
+ return line.replace(/\s+$/g, "");
152
+ }
153
+
154
+ function centerToWidth(text: string, width: number): string {
155
+ const bounded = truncateToWidth(text, width);
156
+ const remaining = Math.max(0, width - displayWidth(bounded));
157
+ const left = Math.floor(remaining / 2);
158
+ return `${" ".repeat(left)}${bounded}${" ".repeat(remaining - left)}`;
159
+ }
160
+
161
+ function padLine(line: string, width: number): string {
162
+ const bounded = displayWidth(line) > width ? truncateToWidth(line, width) : line;
163
+ return `${bounded}${" ".repeat(Math.max(0, width - displayWidth(bounded)))}`;
164
+ }
165
+
166
+ function frameReaction(reaction: string, achievements: Achievement[], innerWidth: number): string[] {
167
+ const maxContentLines = WIDGET_MAX_LINES - 2;
168
+ const trimmedReaction = reaction.trim();
169
+ const achievementLines = achievements.map((achievement) => `🏆 ${achievement.name}`);
170
+ const reactionMax = Math.max(0, maxContentLines - achievementLines.length);
171
+ const reactionLines = trimmedReaction ? wrapReaction(trimmedReaction, innerWidth, reactionMax) : [];
172
+ const content = reactionLines.concat(achievementLines).slice(0, maxContentLines);
173
+ const paddedContent = content.length === 1 ? ["", ...content] : content;
174
+
175
+ const top = `.${"-".repeat(innerWidth + 2)}.`;
176
+ const bottom = `\`${"-".repeat(innerWidth + 2)}'`;
177
+ return [
178
+ top,
179
+ ...paddedContent.map((line) => `| ${DIM_ITALIC}${padLine(line, innerWidth)}${RESET} |`),
180
+ bottom,
181
+ ];
182
+ }
183
+
184
+ /**
185
+ * Render the hero card shared by the OMP, Pi, and Claude Code surfaces.
186
+ * The bubble is deliberately dropped as a unit when the sprite cannot fit.
187
+ */
188
+ export function renderCompanionWidget(
189
+ companion: Companion,
190
+ reaction: ReactionState | null | undefined,
191
+ achievements: Achievement[] = [],
192
+ width: number = getWidgetWidth(),
193
+ frame: number = Math.floor(Date.now() / 700),
194
+ ): string[] {
195
+ const safeWidth = Math.max(1, Math.floor(width));
196
+ const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
197
+ if (companion.bones.hat !== "none" && !art[0]?.trim()) art[0] = HAT_ART[companion.bones.hat];
198
+
199
+ const color = getRarityColor(companion.bones.rarity);
200
+ const label = `${companion.name} ${RARITY_STARS[companion.bones.rarity]}${companion.bones.shiny ? " ✨" : ""}`;
201
+ const artWidth = Math.max(...art.map(displayWidth), 0);
202
+ const labelWidth = displayWidth(label);
203
+ const spriteWidth = Math.min(safeWidth, Math.max(artWidth, labelWidth));
204
+ const clippedArt = art.map((line) => `${color}${padLine(line, spriteWidth)}${RESET}`);
205
+ const clippedLabel = `${color}${centerToWidth(label, spriteWidth)}${RESET}`;
206
+
207
+ const minBubbleInner = 12;
208
+ const tailWidth = 3;
209
+ const maxBubbleInner = safeWidth - spriteWidth - tailWidth - 4;
210
+ const hasContent = Boolean(reaction?.reaction?.trim()) || achievements.length > 0;
211
+ const canFrame = hasContent && maxBubbleInner >= minBubbleInner;
212
+ const bubbleInner = canFrame ? maxBubbleInner : 0;
213
+ const bubble = canFrame ? frameReaction(reaction?.reaction ?? "", achievements, bubbleInner) : [];
214
+ const bubbleWidth = bubbleInner + 4;
215
+ const cardWidth = canFrame ? bubbleWidth + tailWidth + spriteWidth : spriteWidth;
216
+
217
+ const achievementSprites = canFrame
218
+ ? []
219
+ : achievements.map((achievement) => `${DIM_ITALIC}${padLine(`🏆 ${achievement.name}`, spriteWidth)}${RESET}`);
220
+ const spriteBody = [...clippedArt, clippedLabel, ...achievementSprites];
221
+ const height = Math.max(spriteBody.length, bubble.length);
222
+ const connectorRow = canFrame ? Math.min(bubble.length - 2, Math.max(1, Math.floor(bubble.length / 2))) : -1;
223
+
224
+ return Array.from({ length: Math.min(WIDGET_MAX_LINES, height) }, (_, index) => {
225
+ const spriteLine = spriteBody[index] ?? "";
226
+ const sprite = padLine(spriteLine, spriteWidth);
227
+ let body: string;
228
+ if (canFrame) {
229
+ const bubbleLine = bubble[index] ?? " ".repeat(bubbleWidth);
230
+ const tail = index === connectorRow ? "-- " : " ";
231
+ body = `${bubbleLine}${tail}${sprite}`;
232
+ } else {
233
+ body = sprite;
234
+ }
235
+ return `${" ".repeat(Math.max(0, safeWidth - cardWidth))}${body}`;
236
+ });
237
+ }
238
+
239
+ export interface BuddyWidgetScheduler {
240
+ setTimeout(callback: () => void, ms?: number): Timer;
241
+ clearTimer(timer: Timer): void;
242
+ }
243
+
244
+ /**
245
+ * Width-aware buddy widget manager.
246
+ *
247
+ * Renders once when state arrives, then re-renders with the current terminal
248
+ * width after coalescing rapid process.stdout resize events. Callers supply
249
+ * only their harness `setWidget` and the current state; this class owns the
250
+ * resize subscription, debounce, and re-render logic.
251
+ */
252
+ export class BuddyWidget {
253
+ private readonly getWidth: () => number;
254
+ private scheduler: BuddyWidgetScheduler;
255
+ private readonly coalesceMs: number;
256
+ private setWidget: ((lines: string[]) => void) | null = null;
257
+ private state: { companion: Companion; reaction: ReactionState | null; achievements: Achievement[] } | null = null;
258
+ private resizeUnsubscribe: (() => void) | null = null;
259
+ private timer: Timer | null = null;
260
+
261
+ constructor(options?: {
262
+ getWidth?: () => number;
263
+ scheduler?: BuddyWidgetScheduler;
264
+ coalesceMs?: number;
265
+ }) {
266
+ this.getWidth = options?.getWidth ?? getWidgetWidth;
267
+ this.scheduler = options?.scheduler ?? {
268
+ setTimeout: globalThis.setTimeout.bind(globalThis),
269
+ clearTimer: globalThis.clearTimeout.bind(globalThis),
270
+ };
271
+ this.coalesceMs = options?.coalesceMs ?? 50;
272
+ }
273
+
274
+ /**
275
+ * Render immediately with the current state and start (or renew) the shared
276
+ * resize subscription. The `setWidget` callback is the adapter's harness
277
+ * setter bound to the current UI context.
278
+ */
279
+ refresh(
280
+ setWidget: (lines: string[]) => void,
281
+ companion: Companion,
282
+ reaction: ReactionState | null,
283
+ achievements: Achievement[] = [],
284
+ scheduler?: BuddyWidgetScheduler,
285
+ ): void {
286
+ // Cancel any pending resize timer before swapping the scheduler or state
287
+ // so clearTimer is always paired with the scheduler that created the timer.
288
+ this.cancelTimer();
289
+ this.setWidget = setWidget;
290
+ if (scheduler) this.scheduler = scheduler;
291
+ this.state = { companion, reaction, achievements };
292
+ this.renderAndSet();
293
+ this.subscribe();
294
+ }
295
+
296
+ /** Stop reacting to resizes without unsubscribing from the shared listener. */
297
+ clear(): void {
298
+ this.cancelTimer();
299
+ this.state = null;
300
+ }
301
+
302
+ /** Tear down timers and the shared resize listener. */
303
+ dispose(): void {
304
+ this.cancelTimer();
305
+ this.resizeUnsubscribe?.();
306
+ this.resizeUnsubscribe = null;
307
+ this.state = null;
308
+ this.setWidget = null;
309
+ }
310
+
311
+ private subscribe(): void {
312
+ if (this.resizeUnsubscribe) return;
313
+ this.resizeUnsubscribe = subscribeToWidgetResize(() => this.onResize());
314
+ }
315
+
316
+ private onResize(): void {
317
+ // A cleared widget has no state/setter to render; don't schedule work.
318
+ if (!this.state || !this.setWidget) return;
319
+ this.cancelTimer();
320
+ this.timer = this.scheduler.setTimeout(() => {
321
+ this.timer = null;
322
+ try {
323
+ this.renderAndSet();
324
+ } catch (error) {
325
+ console.error("BuddyWidget render failed:", error);
326
+ }
327
+ }, this.coalesceMs);
328
+ }
329
+ private renderAndSet(): void {
330
+ if (!this.state || !this.setWidget) return;
331
+ const width = this.getWidth();
332
+ const lines = renderCompanionWidget(this.state.companion, this.state.reaction, this.state.achievements, width);
333
+ this.setWidget(lines);
334
+ }
335
+
336
+ private cancelTimer(): void {
337
+ if (this.timer !== null) {
338
+ this.scheduler.clearTimer(this.timer);
339
+ this.timer = null;
340
+ }
341
+ }
342
+ }
package/cli/settings.ts CHANGED
@@ -19,7 +19,7 @@ if (!key) {
19
19
  coding-buddy settings
20
20
  ─────────────────────
21
21
  Comment cooldown: ${cfg.commentCooldown}s (0 = no throttling, default 30)
22
- Reaction TTL: ${cfg.reactionTTL}s (0 = permanent, default 0)
22
+ Reaction TTL: ${cfg.reactionTTL}s (0 = permanent, default 900)
23
23
 
24
24
  Change: bun run settings cooldown <seconds>
25
25
  bun run settings ttl <seconds>
package/cli/tui.tsx CHANGED
@@ -1459,7 +1459,7 @@ interface SettingDef {
1459
1459
 
1460
1460
  const SETTING_DEFS: SettingDef[] = [
1461
1461
  { key: "commentCooldown", label: "Comment Cooldown", description: ["Minimum seconds between", "buddy status line comments.", "", "Lower = chatty, Higher = quiet"], type: "number", min: 0, default: "30" },
1462
- { key: "reactionTTL", label: "Reaction TTL", description: ["How long reactions stay", "visible in status line.", "", "0 = permanent"], type: "number", min: 0, default: "0" },
1462
+ { key: "reactionTTL", label: "Reaction TTL", description: ["How long reactions stay", "visible in status line.", "", "0 = permanent"], type: "number", min: 0, default: "900" },
1463
1463
  { key: "bubbleStyle", label: "Bubble Style", description: ["Speech bubble style.", "", 'classic → "quoted"', "round → (parens)"], type: "options", options: ["classic", "round"], default: "classic" },
1464
1464
  { key: "bubblePosition", label: "Bubble Position", description: ["Bubble placement.", "", "top → above buddy", "left → beside buddy"], type: "options", options: ["top", "left"], default: "top" },
1465
1465
  { key: "showRarity", label: "Show Rarity", description: ["Show rarity stars in", "the status line.", "", "true → ★★★★ visible", "false → hidden"], type: "options", options: ["true", "false"], default: "true" },
@@ -142,6 +142,19 @@ export class BuddyCommandService {
142
142
  }));
143
143
  }
144
144
 
145
+ setPersonalityForSlot(
146
+ slot: string,
147
+ personality: string,
148
+ expectedPersonality: string,
149
+ ): Companion | null {
150
+ const trimmed = personality.trim();
151
+ if (!trimmed) throw new Error("Buddy personality cannot be empty.");
152
+ return this.deps.buddies.updateSlot(slot, (companion) => {
153
+ if (companion.personality !== expectedPersonality) return null;
154
+ return { ...companion, personality: trimmed };
155
+ });
156
+ }
157
+
145
158
  saveBuddy(slot?: string): SaveBuddyResult {
146
159
  const { companion, slot: activeSlot } = this.ensureCompanion();
147
160
  if (slot?.trim()) {
package/core/engine.ts CHANGED
@@ -81,6 +81,13 @@ export const EYES = [
81
81
  "\u00b0",
82
82
  ] as const;
83
83
  export type Eye = (typeof EYES)[number];
84
+ const DEFAULT_EYE: Eye = "\u00b0";
85
+
86
+ /** Keep persisted/runtime eye values from leaking descriptor words into art. */
87
+ export function resolveEyeGlyph(eye: unknown): Eye {
88
+ return EYES.find((candidate) => candidate === eye) ?? DEFAULT_EYE;
89
+ }
90
+
84
91
 
85
92
  export const HATS = [
86
93
  "none",
@@ -131,6 +138,8 @@ export interface Companion {
131
138
  hatchedAt: number;
132
139
  userId: string;
133
140
  }
141
+ export const PERSONALITY_MAX_LENGTH = 500;
142
+
134
143
 
135
144
  // ─── Hash: wyhash via Bun.hash, pure JS fallback ───────────────────────────
136
145
  // Matches Zig stdlib wyhash v4.2 (used by Bun.hash). The pure JS implementation
@@ -401,8 +410,8 @@ const FACE_TEMPLATES: Record<Species, string> = {
401
410
  pikachu: "({E}ω{E})",
402
411
  };
403
412
 
404
- export function renderFace(species: Species, eye: Eye): string {
405
- return FACE_TEMPLATES[species].replace(/\{E\}/g, eye);
413
+ export function renderFace(species: Species, eye: string): string {
414
+ return FACE_TEMPLATES[species].replace(/\{E\}/g, resolveEyeGlyph(eye));
406
415
  }
407
416
 
408
417
  export function renderBuddy(bones: BuddyBones): string {
@@ -0,0 +1,15 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /** Explicit opt-in identity shared by the Claude, OMP, and Pi adapters. */
5
+ export function configuredSharedUserId(): string | undefined {
6
+ const value = process.env.CODING_BUDDY_USER_ID?.trim();
7
+ return value || undefined;
8
+ }
9
+
10
+ /** Shared state is opt-in so existing harness-specific worlds stay isolated. */
11
+ export function sharedStateDir(): string | undefined {
12
+ if (!configuredSharedUserId()) return undefined;
13
+ const configured = process.env.CODING_BUDDY_STATE_DIR?.trim();
14
+ return configured || join(homedir(), ".coding-buddy", "shared");
15
+ }
package/core/model.ts CHANGED
@@ -38,7 +38,10 @@ export interface BuddyConfig {
38
38
  bubblePosition: "top" | "left";
39
39
  showRarity: boolean;
40
40
  statusLineEnabled: boolean;
41
+ statuslineDensity?: "auto" | "full" | "compact" | "minimal";
41
42
  turnCommentModel?: BuddyTurnCommentModelConfig;
43
+ /** Optional per-host bound for end-of-turn reaction LLM calls (ms). */
44
+ turnCommentTimeoutMs?: number;
42
45
  }
43
46
 
44
47
  export interface GlobalCounters {
package/core/ports.ts CHANGED
@@ -17,6 +17,10 @@ export interface BuddyRepository {
17
17
  updateActive(transform: (companion: Companion) => Companion): Companion;
18
18
  loadSlot(slot: string): Companion | null;
19
19
  saveSlot(slot: string, companion: Companion): void;
20
+ updateSlot(
21
+ slot: string,
22
+ transform: (companion: Companion) => Companion | null,
23
+ ): Companion | null;
20
24
  deleteSlot(slot: string): void;
21
25
  listSlots(): Array<{ slot: string; companion: Companion }>;
22
26
  loadActiveSlot(): string | null;
package/core/reactions.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BuddyStats, Species, Rarity } from "./engine.ts";
1
+ import { PERSONALITY_MAX_LENGTH, type BuddyStats, type Species, type Rarity } from "./engine.ts";
2
2
 
3
3
  export type ReactionReason =
4
4
  | "hatch" | "pet" | "error" | "test-fail" | "large-diff" | "turn" | "idle"
@@ -675,6 +675,33 @@ export function generatePersonalityPrompt(
675
675
  `Inspiration words: ${vibes.join(", ")}`,
676
676
  shiny ? "SHINY variant — extra special." : "",
677
677
  "",
678
- "Return JSON: {\"name\": \"1-14 chars\", \"personality\": \"2-3 sentences describing behavior\"}",
678
+ "Return JSON: {\"name\": \"1-14 chars\", \"personality\": \"2-3 sentences, 1-500 chars\"}",
679
679
  ].filter(Boolean).join("\n");
680
680
  }
681
+ export function extractGeneratedPersonality(
682
+ text: string,
683
+ maxLength: number = PERSONALITY_MAX_LENGTH,
684
+ ): string | null {
685
+ const trimmed = text.trim();
686
+ if (!trimmed) return null;
687
+
688
+ let parsed: unknown;
689
+ try {
690
+ parsed = JSON.parse(trimmed);
691
+ } catch {
692
+ const match = trimmed.match(/\{[\s\S]*\}/);
693
+ if (!match) return null;
694
+ try {
695
+ parsed = JSON.parse(match[0]);
696
+ } catch {
697
+ return null;
698
+ }
699
+ }
700
+
701
+ if (!parsed || typeof parsed !== "object") return null;
702
+ const personality = (parsed as Record<string, unknown>).personality;
703
+ if (typeof personality !== "string") return null;
704
+ const clean = personality.trim();
705
+ if (!clean || clean.length > maxLength) return null;
706
+ return clean;
707
+ }
@@ -1,10 +1,11 @@
1
- import type { Eye, Species } from "./engine.ts";
1
+ import { resolveEyeGlyph, type Species } from "./engine.ts";
2
2
  import { HAT_ART, SPECIES_ART } from "./art-data.ts";
3
3
 
4
- export function getArtFrame(species: Species, eye: Eye, frame: number = 0): string[] {
4
+ export function getArtFrame(species: Species, eye: string, frame: number = 0): string[] {
5
5
  const frames = SPECIES_ART[species];
6
6
  const f = frames[frame % frames.length];
7
- return f.map((line) => line.replace(/\{E\}/g, eye));
7
+ const glyph = resolveEyeGlyph(eye);
8
+ return f.map((line) => line.replace(/\{E\}/g, glyph));
8
9
  }
9
10
 
10
11
  export { HAT_ART, SPECIES_ART };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -17,7 +17,6 @@
17
17
  "coding-buddy": "cli/index.ts"
18
18
  },
19
19
  "scripts": {
20
- "postinstall": "node scripts/postinstall.cjs",
21
20
  "server": "bun run server/index.ts",
22
21
  "pick": "bun run cli/pick.ts",
23
22
  "hunt": "bun run cli/hunt.ts",
@@ -36,7 +35,11 @@
36
35
  "tui": "bun run cli/tui.tsx",
37
36
  "test": "bun test",
38
37
  "typecheck": "tsc --noEmit",
39
- "gen:emoji-widths": "bun run scripts/gen-emoji-widths.ts"
38
+ "gen:emoji-widths": "bun run scripts/gen-emoji-widths.ts",
39
+ "ci:statusline": "bash scripts/ci/check-statusline.sh",
40
+ "ci:vhs:render": "bash scripts/ci/render-vhs-goldens.sh",
41
+ "ci:vhs:update": "bash scripts/ci/render-vhs-goldens.sh --update",
42
+ "ci:vhs:check": "bash scripts/ci/check-vhs-goldens.sh"
40
43
  },
41
44
  "files": [
42
45
  "core/",
@@ -0,0 +1,125 @@
1
+ # syntax=docker/dockerfile:1.7
2
+ # Platform-matched VHS golden renderer.
3
+ #
4
+ # Goldens MUST be produced on linux/amd64 with the same font + tool pins that
5
+ # GitHub Actions ubuntu-latest uses. Rendering on macOS (or arm64 Linux) makes
6
+ # SSIM comparisons fail on font rasterization alone.
7
+ #
8
+ # Build:
9
+ # docker build --platform linux/amd64 -f scripts/ci/Dockerfile.vhs \
10
+ # -t coding-buddy-vhs:local scripts/ci
11
+ #
12
+ # Run (prefer scripts/ci/render-vhs-goldens.sh, which wraps this image):
13
+ # docker run --rm --platform linux/amd64 \
14
+ # -v "$PWD":/work -w /work coding-buddy-vhs:local \
15
+ # bash scripts/ci/render-vhs-goldens.sh --native [--update]
16
+
17
+ FROM --platform=linux/amd64 ubuntu:24.04
18
+
19
+ ENV DEBIAN_FRONTEND=noninteractive \
20
+ LANG=C.UTF-8 \
21
+ LC_ALL=C.UTF-8 \
22
+ COLORTERM=truecolor \
23
+ CI=
24
+
25
+ # Pin exact Ubuntu 24.04 (noble) package versions so runner image drift does
26
+ # not silently re-rasterize glyphs. Bump deliberately when re-baselining.
27
+ #
28
+ # Includes Chromium shared libs for vhs/rod headless browser, plus the fonts
29
+ # used by the tapes (DejaVu mono + Noto CJK/emoji).
30
+ RUN apt-get update -qq \
31
+ && apt-get install -y -qq --no-install-recommends \
32
+ ca-certificates=20260601~24.04.1 \
33
+ curl=8.5.0-2ubuntu10.11 \
34
+ xz-utils=5.6.1+really5.4.5-1ubuntu0.3 \
35
+ unzip=6.0-28ubuntu4.1 \
36
+ jq=1.7.1-3ubuntu0.24.04.2 \
37
+ python3=3.12.3-0ubuntu2.1 \
38
+ ffmpeg=7:6.1.1-3ubuntu5 \
39
+ ttyd=1.7.4-1build2 \
40
+ fonts-dejavu-core=2.37-8 \
41
+ fonts-noto-core=20201225-2 \
42
+ fonts-noto-mono=20201225-2 \
43
+ fonts-noto-color-emoji=2.047-0ubuntu0.24.04.1 \
44
+ fonts-noto-cjk=1:20230817+repack1-3 \
45
+ fonts-liberation=1:2.1.5-3 \
46
+ fontconfig=2.15.0-1.1ubuntu2 \
47
+ findutils=4.9.0-5build1 \
48
+ bash=5.2.21-2ubuntu4 \
49
+ libnss3=2:3.98-1ubuntu0.2 \
50
+ libnspr4=2:4.35-1.1build1 \
51
+ libatk1.0-0t64=2.52.0-1build1 \
52
+ libatk-bridge2.0-0t64=2.52.0-1build1 \
53
+ libatspi2.0-0t64=2.52.0-1build1 \
54
+ libcups2t64=2.4.7-1.2ubuntu7.14 \
55
+ libdrm2=2.4.125-1ubuntu0.1~24.04.2 \
56
+ libxkbcommon0=1.6.0-1build1 \
57
+ libxcomposite1=1:0.4.5-1build3 \
58
+ libxdamage1=1:1.1.6-1build1 \
59
+ libxfixes3=1:6.0.0-2build1 \
60
+ libxrandr2=2:1.5.2-2build1 \
61
+ libgbm1=25.2.8-0ubuntu0.24.04.2 \
62
+ libasound2t64=1.2.11-1ubuntu0.3 \
63
+ libpango-1.0-0=1.52.1+ds-1build1 \
64
+ libcairo2=1.18.0-3build1 \
65
+ libxshmfence1=1.3-1build5 \
66
+ libglib2.0-0t64=2.80.0-6ubuntu3.8 \
67
+ libgtk-3-0t64=3.24.41-4ubuntu1.3 \
68
+ libx11-6=2:1.8.7-1build1 \
69
+ libx11-xcb1=2:1.8.7-1build1 \
70
+ libxcb1=1.15-1ubuntu2 \
71
+ libxext6=2:1.3.4-1build2 \
72
+ libxi6=2:1.8.1-1build1 \
73
+ && rm -rf /var/lib/apt/lists/*
74
+
75
+ # Pin vhs binary.
76
+ ARG VHS_VERSION=0.11.0
77
+ RUN bash -lc "set -euo pipefail \
78
+ && TMP=\$(mktemp -d) \
79
+ && BASE=https://github.com/charmbracelet/vhs/releases/download/v${VHS_VERSION} \
80
+ && curl -fsSL -o \"\$TMP/vhs.tgz\" \"\${BASE}/vhs_${VHS_VERSION}_Linux_x86_64.tar.gz\" \
81
+ && tar -xzf \"\$TMP/vhs.tgz\" -C \"\$TMP\" \
82
+ && BIN=\$(find \"\$TMP\" -type f -name vhs | head -n1) \
83
+ && install -m 755 \"\$BIN\" /usr/local/bin/vhs \
84
+ && rm -rf \"\$TMP\" \
85
+ && vhs --version"
86
+
87
+ # Bun (baseline build — OrbStack/QEMU amd64 often lacks AVX).
88
+ # Install system-wide so non-root `docker run --user` still finds it.
89
+ ARG BUN_VERSION=1.3.9
90
+ RUN bash -lc "set -euo pipefail \
91
+ && TMP=\$(mktemp -d) \
92
+ && curl -fsSL -o \"\$TMP/bun.zip\" \
93
+ \"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip\" \
94
+ && unzip -q \"\$TMP/bun.zip\" -d \"\$TMP\" \
95
+ && install -m 755 \"\$TMP/bun-linux-x64-baseline/bun\" /usr/local/bin/bun \
96
+ && rm -rf \"\$TMP\" \
97
+ && bun --version"
98
+
99
+ COPY fonts.conf /etc/fonts/conf.d/99-coding-buddy-vhs.conf
100
+ RUN fc-cache -f \
101
+ && fc-match "DejaVu Sans Mono" \
102
+ && fc-match "Noto Color Emoji" \
103
+ && fc-match "Noto Sans CJK SC"
104
+
105
+ # Chromium refuses to start as root without --no-sandbox. Prefer the stock
106
+ # ubuntu user (uid 1000 on noble) when present; otherwise create vhs:1000.
107
+ RUN if id -u ubuntu >/dev/null 2>&1; then \
108
+ usermod -l vhs ubuntu; \
109
+ groupmod -n vhs ubuntu; \
110
+ mv /home/ubuntu /home/vhs; \
111
+ usermod -d /home/vhs -s /bin/bash vhs; \
112
+ else \
113
+ useradd --create-home --uid 1000 --shell /bin/bash vhs; \
114
+ fi \
115
+ && mkdir -p /home/vhs/.cache /home/vhs/.config \
116
+ && chown -R 1000:1000 /home/vhs
117
+
118
+ ENV HOME=/home/vhs \
119
+ XDG_CACHE_HOME=/home/vhs/.cache \
120
+ XDG_CONFIG_HOME=/home/vhs/.config
121
+
122
+ WORKDIR /work
123
+ USER vhs
124
+ ENTRYPOINT []
125
+ CMD ["bash", "scripts/ci/render-vhs-goldens.sh", "--native"]