@ramarivera/coding-buddy 0.8.0 → 0.9.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 (52) 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 +30 -1
  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 +30 -1
  13. package/adapters/shared/file-storage.ts +21 -0
  14. package/adapters/shared/widget-layout.ts +134 -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 +2 -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 -2
  25. package/scripts/ci/Dockerfile.vhs +125 -0
  26. package/scripts/ci/check-statusline.sh +328 -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 +99 -0
  34. package/scripts/ci/render-vhs-goldens.sh +235 -0
  35. package/scripts/ci/vhs-run-statusline.sh +16 -0
  36. package/scripts/gen-emoji-widths.ts +41 -17
  37. package/scripts/paths.sh +3 -1
  38. package/server/art.ts +45 -13
  39. package/server/hooks/buddy-comment.ts +166 -17
  40. package/server/hooks/file-type-react.ts +1 -1
  41. package/server/hooks/mood-react.ts +1 -1
  42. package/server/hooks/name-react.ts +1 -1
  43. package/server/hooks/react.ts +1 -1
  44. package/server/hooks/reaction-data.ts +10 -3
  45. package/server/index.ts +67 -27
  46. package/server/path.ts +3 -0
  47. package/server/state.ts +63 -17
  48. package/skills/buddy/SKILL.md +1 -1
  49. package/statusline/buddy-status.sh +351 -59
  50. package/statusline/emoji-text.data +3 -0
  51. package/statusline/emoji-widths.data +2 -3
  52. package/statusline/substatus.sh +52 -10
@@ -3,9 +3,17 @@ import type { Achievement } from "../../core/achievements.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
4
  import { renderAchievementsSummary, renderBuddyWidget } from "./renderers.ts";
5
5
  import { OmpBuddyStorage } from "./storage.ts";
6
+ import { getWidgetWidth, subscribeToWidgetResize } from "../shared/widget-layout.ts";
6
7
 
7
8
  export class OmpBuddyUI {
8
9
  private readonly timers: Array<{ timer: Timer; clear: (timer: Timer) => void }> = [];
10
+ private resizeUnsubscribe: (() => void) | null = null;
11
+ private rendered: {
12
+ ctx: OmpBuddyUiContext;
13
+ companion: Companion;
14
+ reaction: ReactionState | null;
15
+ achievements: Achievement[];
16
+ } | null = null;
9
17
 
10
18
  constructor(private readonly storage: OmpBuddyStorage) {}
11
19
 
@@ -19,6 +27,13 @@ export class OmpBuddyUI {
19
27
  }
20
28
  }
21
29
 
30
+ dispose(): void {
31
+ this.cancelTimers();
32
+ this.resizeUnsubscribe?.();
33
+ this.resizeUnsubscribe = null;
34
+ this.rendered = null;
35
+ }
36
+
22
37
  refresh(
23
38
  ctx: OmpBuddyUiContext,
24
39
  companion: Companion,
@@ -27,10 +42,12 @@ export class OmpBuddyUI {
27
42
  ): void {
28
43
  const currentReaction = reaction ?? null;
29
44
  const muted = this.storage.isMuted();
45
+ this.rendered = { ctx, companion, reaction: currentReaction, achievements: [...achievements] };
46
+ this.subscribeToResize();
30
47
  ctx.ui.setStatus("buddy", undefined);
31
48
  ctx.ui.setWidget(
32
49
  "buddy",
33
- renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
50
+ renderBuddyWidget(companion, muted ? null : currentReaction, achievements, getWidgetWidth()),
34
51
  );
35
52
 
36
53
  this.cancelTimers();
@@ -66,9 +83,21 @@ export class OmpBuddyUI {
66
83
  unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
67
84
  remaining: Achievement[],
68
85
  ): void {
86
+ this.rendered = null;
69
87
  ctx.ui.setWidget("buddy", renderAchievementsSummary(unlocked, remaining));
70
88
  ctx.ui.notify(`Achievements: ${unlocked.length} unlocked`, "info");
71
89
  }
90
+
91
+ private subscribeToResize(): void {
92
+ if (this.resizeUnsubscribe) return;
93
+ const resizeHandler = () => {
94
+ if (!this.rendered) return;
95
+ const { ctx, companion, reaction, achievements } = this.rendered;
96
+ const muted = this.storage.isMuted();
97
+ ctx.ui.setWidget("buddy", renderBuddyWidget(companion, muted ? null : reaction, achievements, getWidgetWidth()));
98
+ };
99
+ this.resizeUnsubscribe = subscribeToWidgetResize(resizeHandler);
100
+ }
72
101
  }
73
102
 
74
103
  function getScheduler(
@@ -13,7 +13,7 @@ import { complete, type AssistantMessage, type TextContent, type UserMessage } f
13
13
  import { BuddyCommandService, type ReactionResult } from "../../core/command-service.ts";
14
14
  import type { Achievement } from "../../core/achievements.ts";
15
15
  import type { BuddyTurnCommentModelConfig, Companion } from "../../core/model.ts";
16
- import { getNameReaction, getSuccessReaction, isNameMentioned } from "../../core/reactions.ts";
16
+ import { extractGeneratedPersonality, generatePersonalityPrompt, getNameReaction, getSuccessReaction, isNameMentioned } from "../../core/reactions.ts";
17
17
  import { PiBuddyStorage } from "./storage.ts";
18
18
  import { buildBuddyReactionPrompt, buildBuddyReactionSystemPrompt, normalizeBuddyComment, stripBuddyComments } from "./prompt.ts";
19
19
  import { PiBuddyUI } from "./ui.ts";
@@ -29,19 +29,32 @@ interface RegisterBuddyEventsDeps {
29
29
  storage: PiBuddyStorage;
30
30
  ui: PiBuddyUI;
31
31
  logger: PiBuddyLog;
32
+ completeTurnComment?: TurnCommentCompleter;
33
+ completePersonality?: PersonalityCompleter;
32
34
  }
33
35
 
34
36
  export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsDeps): void {
35
37
  pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
36
38
  const result = deps.service.startSession();
39
+ let companion = result.companion;
40
+ if (result.created) {
41
+ const generated = await generatePersonality(ctx, companion, deps.logger, deps.completePersonality, {
42
+ modelOverride: deps.storage.loadPiConfig().turnCommentModel,
43
+ });
44
+ if (generated) {
45
+ const updated = deps.service.setPersonalityForSlot(result.slot, generated, result.companion.personality);
46
+ if (updated) companion = updated;
47
+ }
48
+ }
49
+ companion = deps.storage.loadActive() ?? companion;
37
50
  deps.logger.info("session_start", {
38
- companion: result.companion.name,
39
- species: result.companion.bones.species,
51
+ companion: companion.name,
52
+ species: companion.bones.species,
40
53
  created: result.created,
41
54
  });
42
- deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
55
+ deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), result.achievements);
43
56
  if (result.created) {
44
- ctx.ui.notify(`A new buddy hatched: ${result.companion.name}`, "info");
57
+ ctx.ui.notify(`A new buddy hatched: ${companion.name}`, "info");
45
58
  }
46
59
  deps.ui.notifyAchievements(ctx, result.achievements);
47
60
  });
@@ -152,14 +165,17 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
152
165
  deps.ui.refresh(ctx, progress.companion, null, progress.achievements);
153
166
  return;
154
167
  }
155
-
168
+ const config = deps.storage.loadPiConfig();
156
169
  const generated = await generateTurnComment(
157
170
  ctx,
158
171
  progress.companion,
159
172
  event,
160
173
  deps.logger,
161
- complete,
162
- deps.storage.loadPiConfig().turnCommentModel,
174
+ deps.completeTurnComment ?? complete,
175
+ {
176
+ modelOverride: config.turnCommentModel,
177
+ timeoutMs: config.turnCommentTimeoutMs,
178
+ },
163
179
  );
164
180
  if (!generated.comment) {
165
181
  const assistantLength = isAssistantMessage(event.message) ? getAssistantText(event.message).length : 0;
@@ -179,7 +195,7 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
179
195
  deps.ui.notifyAchievements(ctx, achievements);
180
196
  });
181
197
  pi.on("session_shutdown", () => {
182
- deps.ui.cancelTimers();
198
+ deps.ui.dispose();
183
199
  });
184
200
  }
185
201
  function diagnosticPreviewsEnabled(): boolean {
@@ -283,19 +299,34 @@ function getAssistantText(message: AssistantMessage): string {
283
299
  .join("\n");
284
300
  }
285
301
 
286
- type TurnCommentCompleter = (
302
+ export type TurnCommentCompleter = (
287
303
  model: Parameters<typeof complete>[0],
288
304
  context: Parameters<typeof complete>[1],
289
305
  options: Parameters<typeof complete>[2],
290
306
  ) => Promise<AssistantMessage>;
307
+
308
+ /** Well under typical harness handler budgets; cosmetic reactions must never stall a turn. */
309
+ export const TURN_COMMENT_TIMEOUT_MS = 5_000;
310
+
311
+ export function resolveTurnCommentTimeoutMs(timeoutMs: number | undefined): number {
312
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
313
+ ? timeoutMs
314
+ : TURN_COMMENT_TIMEOUT_MS;
315
+ }
316
+
291
317
  export async function generateTurnComment(
292
318
  ctx: ExtensionContext,
293
319
  companion: Companion,
294
320
  event: TurnEndEvent,
295
321
  logger: PiBuddyLog,
296
322
  completeTurnComment: TurnCommentCompleter = complete,
297
- modelOverride?: BuddyTurnCommentModelConfig,
323
+ options: {
324
+ modelOverride?: BuddyTurnCommentModelConfig;
325
+ timeoutMs?: number;
326
+ } = {},
298
327
  ): Promise<{ comment: string | null; source: "llm" | "fallback" | "none" }> {
328
+ const { modelOverride } = options;
329
+ const timeoutMs = resolveTurnCommentTimeoutMs(options.timeoutMs);
299
330
  const assistantText = isAssistantMessage(event.message) ? getAssistantText(event.message) : "";
300
331
  if (!assistantText.trim()) {
301
332
  logger.warn("turn_comment_skipped", { reason: "empty_assistant_text" });
@@ -303,91 +334,144 @@ export async function generateTurnComment(
303
334
  }
304
335
 
305
336
  const turnCommentModel = resolveTurnCommentModel(ctx, logger, modelOverride);
306
- if (turnCommentModel) {
307
- const toolResultsText = getToolResultsText(event);
308
- const userText = getUserPromptText(ctx);
309
- const systemPrompt = buildBuddyReactionSystemPrompt(companion);
310
- const promptText = buildBuddyReactionPrompt(companion, assistantText, toolResultsText, userText);
311
- logger.info("turn_comment_llm_attempt", {
312
- modelProvider: turnCommentModel.provider,
313
- modelId: turnCommentModel.id,
337
+ if (!turnCommentModel) {
338
+ logger.warn("turn_comment_llm_skipped", { reason: "no_model" });
339
+ const fallback = deriveTurnComment(companion, event.message);
340
+ logger.warn("turn_comment_fallback", {
341
+ fallbackLength: fallback?.length ?? 0,
314
342
  assistantLength: assistantText.length,
315
- toolLength: toolResultsText.length,
316
- userTextLength: userText.length,
317
- promptLength: promptText.length,
318
- systemPromptLength: systemPrompt.length,
319
- toolResultCount: event.toolResults.length,
320
343
  });
321
- const promptDebugData: Record<string, unknown> = {
322
- assistantLength: assistantText.length,
323
- toolLength: toolResultsText.length,
324
- userTextLength: userText.length,
325
- };
326
- if (diagnosticPreviewsEnabled()) {
327
- promptDebugData.systemPromptPreview = systemPrompt.slice(0, 800);
328
- promptDebugData.promptPreview = promptText.slice(0, 1200);
329
- promptDebugData.userTextPreview = userText.slice(0, 200);
330
- promptDebugData.assistantPreview = assistantText.slice(0, 200);
331
- promptDebugData.toolPreview = toolResultsText.slice(0, 200);
344
+ return { comment: fallback, source: fallback ? "fallback" : "none" };
345
+ }
346
+
347
+ const toolResultsText = getToolResultsText(event);
348
+ const userText = getUserPromptText(ctx);
349
+ const systemPrompt = buildBuddyReactionSystemPrompt(companion);
350
+ const promptText = buildBuddyReactionPrompt(companion, assistantText, toolResultsText, userText);
351
+ logger.info("turn_comment_llm_attempt", {
352
+ modelProvider: turnCommentModel.provider,
353
+ modelId: turnCommentModel.id,
354
+ assistantLength: assistantText.length,
355
+ toolLength: toolResultsText.length,
356
+ userTextLength: userText.length,
357
+ promptLength: promptText.length,
358
+ systemPromptLength: systemPrompt.length,
359
+ toolResultCount: event.toolResults.length,
360
+ timeoutMs,
361
+ });
362
+ const promptDebugData: Record<string, unknown> = {
363
+ assistantLength: assistantText.length,
364
+ toolLength: toolResultsText.length,
365
+ userTextLength: userText.length,
366
+ };
367
+ if (diagnosticPreviewsEnabled()) {
368
+ promptDebugData.systemPromptPreview = systemPrompt.slice(0, 800);
369
+ promptDebugData.promptPreview = promptText.slice(0, 1200);
370
+ promptDebugData.userTextPreview = userText.slice(0, 200);
371
+ promptDebugData.assistantPreview = assistantText.slice(0, 200);
372
+ promptDebugData.toolPreview = toolResultsText.slice(0, 200);
373
+ }
374
+ logger.debug("turn_comment_llm_prompt", promptDebugData);
375
+
376
+ const userMessage: UserMessage = {
377
+ role: "user",
378
+ content: [{ type: "text", text: promptText }],
379
+ timestamp: Date.now(),
380
+ };
381
+
382
+ const controller = new AbortController();
383
+ let timedOut = false;
384
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
385
+ const timeoutPromise = new Promise<never>((_, reject) => {
386
+ timeoutId = setTimeout(() => {
387
+ timedOut = true;
388
+ controller.abort(new Error("Turn comment generation timed out"));
389
+ reject(new Error("Turn comment generation timed out"));
390
+ }, timeoutMs);
391
+ });
392
+
393
+ const workPromise = (async (): Promise<string | null> => {
394
+ let auth: Awaited<ReturnType<ExtensionContext["modelRegistry"]["getApiKeyAndHeaders"]>>;
395
+ try {
396
+ auth = await ctx.modelRegistry.getApiKeyAndHeaders(turnCommentModel);
397
+ } catch (error) {
398
+ logger.warn("turn_comment_auth_unavailable", {
399
+ message: error instanceof Error ? error.message : String(error),
400
+ });
401
+ return null;
332
402
  }
333
- logger.debug("turn_comment_llm_prompt", promptDebugData);
334
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(turnCommentModel);
403
+
335
404
  logger.debug("turn_comment_auth", {
336
405
  ok: auth.ok,
337
406
  hasApiKey: auth.ok ? !!auth.apiKey : false,
338
407
  headerKeys: auth.ok ? Object.keys(auth.headers ?? {}) : [],
339
408
  });
340
- if (auth.ok && auth.apiKey) {
341
- const userMessage: UserMessage = {
342
- role: "user",
343
- content: [{ type: "text", text: promptText }],
344
- timestamp: Date.now(),
345
- };
346
-
347
- try {
348
- const response = await completeTurnComment(
349
- turnCommentModel,
350
- {
351
- systemPrompt,
352
- messages: [userMessage],
353
- },
354
- {
355
- apiKey: auth.apiKey,
356
- headers: auth.headers,
357
- },
358
- );
359
-
360
- if (response.stopReason !== "aborted") {
361
- const text = response.content
362
- .filter((block): block is TextContent => block.type === "text")
363
- .map((block) => block.text)
364
- .join("\n");
365
- const normalized = normalizeBuddyComment(text);
366
- logger.info("turn_comment_llm_result", {
367
- stopReason: response.stopReason,
368
- errorMessage: "errorMessage" in response ? (response as { errorMessage?: string }).errorMessage : undefined,
369
- contentTypes: response.content.map((block) => block.type),
370
- contentCount: response.content.length,
371
- rawLength: text.length,
372
- normalizedLength: normalized.length,
373
- });
374
- if (normalized) return { comment: normalized, source: "llm" };
375
- logger.warn("turn_comment_llm_empty", { rawLength: text.length });
376
- }
377
- } catch (error) {
378
- logger.error("turn_comment_llm_error", {
379
- message: error instanceof Error ? error.message : String(error),
380
- stack: error instanceof Error ? error.stack : undefined,
381
- });
382
- }
383
- } else {
409
+ if (!auth.ok || !auth.apiKey) {
384
410
  logger.warn("turn_comment_auth_unavailable", {
385
411
  ok: auth.ok,
386
412
  message: auth.ok ? "missing api key" : auth.error,
387
413
  });
414
+ return null;
415
+ }
416
+
417
+ const response = await completeTurnComment(
418
+ turnCommentModel,
419
+ {
420
+ systemPrompt,
421
+ messages: [userMessage],
422
+ },
423
+ {
424
+ apiKey: auth.apiKey,
425
+ headers: auth.headers,
426
+ signal: controller.signal,
427
+ },
428
+ );
429
+
430
+ if (response.stopReason === "aborted") {
431
+ logger.warn("turn_comment_llm_aborted", {
432
+ errorMessage: "errorMessage" in response ? response.errorMessage : undefined,
433
+ });
434
+ return null;
435
+ }
436
+
437
+ const text = response.content
438
+ .filter((block): block is TextContent => block.type === "text")
439
+ .map((block) => block.text)
440
+ .join("\n");
441
+ const normalized = normalizeBuddyComment(text);
442
+ logger.info("turn_comment_llm_result", {
443
+ stopReason: response.stopReason,
444
+ errorMessage: "errorMessage" in response ? response.errorMessage : undefined,
445
+ contentTypes: response.content.map((block) => block.type),
446
+ contentCount: response.content.length,
447
+ rawLength: text.length,
448
+ normalizedLength: normalized.length,
449
+ });
450
+ if (normalized) return normalized;
451
+ logger.warn("turn_comment_llm_empty", { rawLength: text.length });
452
+ return null;
453
+ })();
454
+
455
+ // Prevent unhandled rejection if timeout wins the race first.
456
+ workPromise.catch(() => {});
457
+
458
+ try {
459
+ const comment = await Promise.race([workPromise, timeoutPromise]);
460
+ clearTimeout(timeoutId);
461
+ if (comment) return { comment, source: "llm" };
462
+ } catch (error) {
463
+ clearTimeout(timeoutId);
464
+ if (timedOut) {
465
+ logger.warn("turn_comment_llm_timeout", {
466
+ timeoutMs,
467
+ message: error instanceof Error ? error.message : String(error),
468
+ });
469
+ } else {
470
+ logger.error("turn_comment_llm_error", {
471
+ message: error instanceof Error ? error.message : String(error),
472
+ stack: error instanceof Error ? error.stack : undefined,
473
+ });
388
474
  }
389
- } else {
390
- logger.warn("turn_comment_llm_skipped", { reason: "no_model" });
391
475
  }
392
476
 
393
477
  const fallback = deriveTurnComment(companion, event.message);
@@ -397,6 +481,119 @@ export async function generateTurnComment(
397
481
  });
398
482
  return { comment: fallback, source: fallback ? "fallback" : "none" };
399
483
  }
484
+ export type PersonalityCompleter = (
485
+ model: Parameters<typeof complete>[0],
486
+ context: Parameters<typeof complete>[1],
487
+ options: Parameters<typeof complete>[2],
488
+ ) => Promise<AssistantMessage>;
489
+ const PERSONALITY_GENERATION_TIMEOUT_MS = 10_000;
490
+
491
+ export async function generatePersonality(
492
+ ctx: ExtensionContext,
493
+ companion: Companion,
494
+ logger: PiBuddyLog,
495
+ completePersonality: PersonalityCompleter = complete,
496
+ options: {
497
+ modelOverride?: BuddyTurnCommentModelConfig;
498
+ timeoutMs?: number;
499
+ } = {},
500
+ ): Promise<string | null> {
501
+ const { modelOverride, timeoutMs = PERSONALITY_GENERATION_TIMEOUT_MS } = options;
502
+ const model = resolveTurnCommentModel(ctx, logger, modelOverride);
503
+ if (!model) {
504
+ logger.warn("personality_generation_skipped", { reason: "no_model" });
505
+ return null;
506
+ }
507
+
508
+ const promptText = generatePersonalityPrompt(
509
+ companion.bones.species,
510
+ companion.bones.rarity,
511
+ { ...companion.bones.stats } as Record<string, number>,
512
+ companion.bones.shiny,
513
+ );
514
+ const userMessage: UserMessage = {
515
+ role: "user",
516
+ content: [{ type: "text", text: promptText }],
517
+ timestamp: Date.now(),
518
+ };
519
+
520
+ const controller = new AbortController();
521
+ let timedOut = false;
522
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
523
+ const timeoutPromise = new Promise<never>((_, reject) => {
524
+ timeoutId = setTimeout(() => {
525
+ timedOut = true;
526
+ controller.abort(new Error("Personality generation timed out"));
527
+ reject(new Error("Personality generation timed out"));
528
+ }, timeoutMs);
529
+ });
530
+
531
+ const workPromise = (async (): Promise<string | null> => {
532
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
533
+ logger.debug("personality_generation_auth", {
534
+ ok: auth.ok,
535
+ hasApiKey: auth.ok ? !!auth.apiKey : false,
536
+ headerKeys: auth.ok ? Object.keys(auth.headers ?? {}) : [],
537
+ });
538
+ if (!auth.ok || !auth.apiKey) {
539
+ logger.warn("personality_generation_auth_unavailable", {
540
+ ok: auth.ok,
541
+ message: auth.ok ? "missing api key" : auth.error,
542
+ });
543
+ return null;
544
+ }
545
+
546
+ const response = await completePersonality(
547
+ model,
548
+ { messages: [userMessage] },
549
+ { apiKey: auth.apiKey, headers: auth.headers, signal: controller.signal },
550
+ );
551
+
552
+ if (response.stopReason === "aborted") {
553
+ logger.warn("personality_generation_aborted", {
554
+ errorMessage: response.errorMessage,
555
+ });
556
+ return null;
557
+ }
558
+
559
+ const text = response.content
560
+ .filter((block): block is TextContent => block.type === "text")
561
+ .map((block) => block.text)
562
+ .join("\n");
563
+ const personality = extractGeneratedPersonality(text);
564
+ logger.info("personality_generation_result", {
565
+ stopReason: response.stopReason,
566
+ rawLength: text.length,
567
+ hasPersonality: !!personality,
568
+ });
569
+ if (personality) return personality;
570
+ logger.warn("personality_generation_malformed", { rawLength: text.length });
571
+ return null;
572
+ })();
573
+
574
+ workPromise.catch(() => {});
575
+
576
+ let result: string | null;
577
+ try {
578
+ result = await Promise.race([workPromise, timeoutPromise]);
579
+ clearTimeout(timeoutId);
580
+ } catch (error) {
581
+ clearTimeout(timeoutId);
582
+ if (timedOut) {
583
+ logger.warn("personality_generation_timeout", {
584
+ message: error instanceof Error ? error.message : String(error),
585
+ });
586
+ } else {
587
+ logger.error("personality_generation_error", {
588
+ message: error instanceof Error ? error.message : String(error),
589
+ stack: error instanceof Error ? error.stack : undefined,
590
+ });
591
+ }
592
+ result = null;
593
+ }
594
+
595
+ return result;
596
+ }
400
597
 
401
598
  export interface PiTurnCommentModelContext {
402
599
  model: ExtensionContext["model"];
@@ -3,12 +3,14 @@ import { BuddyCommandService } from "../../core/command-service.ts";
3
3
  import { PiIdentityProvider } from "./identity.ts";
4
4
  import { PiBuddyLogger } from "./logger.ts";
5
5
  import { registerBuddyCommands } from "./commands.ts";
6
- import { registerBuddyEvents } from "./events.ts";
6
+ import { registerBuddyEvents, type TurnCommentCompleter, type PersonalityCompleter } from "./events.ts";
7
7
  import { PiBuddyStorage } from "./storage.ts";
8
8
  import { PiBuddyUI } from "./ui.ts";
9
9
 
10
10
  export interface PiBuddyExtensionOptions {
11
11
  stateDir?: string;
12
+ completeTurnComment?: TurnCommentCompleter;
13
+ completePersonality?: PersonalityCompleter;
12
14
  }
13
15
 
14
16
  export default function registerPiBuddyExtension(
@@ -28,5 +30,12 @@ export default function registerPiBuddyExtension(
28
30
  const ui = new PiBuddyUI(storage);
29
31
 
30
32
  registerBuddyCommands(pi, { service, storage, ui });
31
- registerBuddyEvents(pi, { service, storage, ui, logger });
33
+ registerBuddyEvents(pi, {
34
+ service,
35
+ storage,
36
+ ui,
37
+ logger,
38
+ completeTurnComment: options.completeTurnComment,
39
+ completePersonality: options.completePersonality,
40
+ });
32
41
  }
@@ -1,60 +1,16 @@
1
- import { RARITY_STARS } from "../../core/engine.ts";
2
- import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
3
- import type { Companion, ReactionState } from "../../core/model.ts";
4
1
  import type { Achievement } from "../../core/achievements.ts";
5
- import { getRarityColor } from "../../server/theme.ts";
6
- import {
7
- composeDetailsAndArt,
8
- getDetailsWidth,
9
- getWidgetWidth,
10
- WIDGET_MAX_LINES,
11
- wrapReaction,
12
- } from "../shared/widget-layout.ts";
2
+ import type { Companion, ReactionState } from "../../core/model.ts";
3
+ import { getWidgetWidth, renderCompanionWidget, WIDGET_MAX_LINES } from "../shared/widget-layout.ts";
13
4
 
14
5
  export const PI_WIDGET_MAX_LINES = WIDGET_MAX_LINES;
15
6
 
16
- const RESET = "\x1b[0m";
17
- const DIM_ITALIC = "\x1b[2;3m";
18
-
19
- function trimArt(line: string): string {
20
- return line.replace(/\s+$/g, "");
21
- }
22
-
23
7
  export function renderBuddyWidget(
24
8
  companion: Companion,
25
9
  reaction?: ReactionState | null,
26
10
  achievements: Achievement[] = [],
27
11
  width: number = getWidgetWidth(),
28
12
  ): string[] {
29
- const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
30
- const sideWidth = getDetailsWidth(art, width);
31
- const color = getRarityColor(companion.bones.rarity);
32
- const stars = RARITY_STARS[companion.bones.rarity];
33
- const shiny = companion.bones.shiny ? " ✨" : "";
34
- const details = [
35
- `${color}${companion.name} ${stars}${shiny}${RESET}`,
36
- `${companion.bones.rarity} ${companion.bones.species}`,
37
- ];
38
-
39
- if (reaction?.reaction) {
40
- const remainingLines = Math.max(1, PI_WIDGET_MAX_LINES - details.length);
41
- details.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
42
- }
43
-
44
- for (const achievement of achievements) {
45
- if (details.length >= PI_WIDGET_MAX_LINES) break;
46
- details.push(`🏆 ${achievement.name}`);
47
- }
48
-
49
- return composeDetailsAndArt(details, art, width, PI_WIDGET_MAX_LINES);
50
- }
51
-
52
- function getFullArtFrame(companion: Companion, frame: number): string[] {
53
- const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
54
- if (companion.bones.hat !== "none" && !(art[0] ?? "").trim()) {
55
- art[0] = HAT_ART[companion.bones.hat];
56
- }
57
- return art.map(trimArt);
13
+ return renderCompanionWidget(companion, reaction, achievements, width);
58
14
  }
59
15
 
60
16
  export function renderBuddyStats(companion: Companion): string[] {
@@ -1,5 +1,6 @@
1
1
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
2
2
  import { join } from "node:path";
3
+ import { sharedStateDir } from "../../core/identity.ts";
3
4
  import {
4
5
  FileBuddyStorage,
5
6
  slugifySlot,
@@ -8,8 +9,12 @@ import {
8
9
 
9
10
  export const DEFAULT_PI_BUDDY_STATE_DIR = join(getAgentDir(), "buddy");
10
11
 
12
+ function defaultPiBuddyStateDir(): string {
13
+ return sharedStateDir() ?? DEFAULT_PI_BUDDY_STATE_DIR;
14
+ }
15
+
11
16
  export class PiBuddyStorage extends FileBuddyStorage {
12
- constructor(stateDir = DEFAULT_PI_BUDDY_STATE_DIR) {
17
+ constructor(stateDir = defaultPiBuddyStateDir()) {
13
18
  super(stateDir);
14
19
  }
15
20