@ramarivera/coding-buddy 0.7.3 → 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 (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/adapters/omp/context.ts +1 -1
  5. package/adapters/omp/events.ts +286 -81
  6. package/adapters/omp/index.ts +3 -1
  7. package/adapters/omp/renderers.ts +4 -54
  8. package/adapters/omp/storage.ts +6 -1
  9. package/adapters/omp/ui.ts +30 -1
  10. package/adapters/pi/events.ts +280 -83
  11. package/adapters/pi/index.ts +11 -2
  12. package/adapters/pi/renderers.ts +3 -47
  13. package/adapters/pi/storage.ts +6 -1
  14. package/adapters/pi/ui.ts +30 -1
  15. package/adapters/shared/file-storage.ts +21 -0
  16. package/adapters/shared/widget-layout.ts +134 -2
  17. package/cli/settings.ts +1 -1
  18. package/cli/tui.tsx +1 -1
  19. package/core/command-service.ts +13 -0
  20. package/core/engine.ts +11 -2
  21. package/core/identity.ts +15 -0
  22. package/core/model.ts +2 -0
  23. package/core/ports.ts +4 -0
  24. package/core/reactions.ts +29 -2
  25. package/core/render-model.ts +4 -3
  26. package/hooks/file-type-react.sh +1 -1
  27. package/hooks/mood-react.sh +1 -1
  28. package/hooks/name-react.sh +1 -1
  29. package/hooks/react.sh +1 -1
  30. package/package.json +6 -2
  31. package/scripts/ci/Dockerfile.vhs +125 -0
  32. package/scripts/ci/check-statusline.sh +338 -0
  33. package/scripts/ci/check-vhs-goldens.sh +97 -0
  34. package/scripts/ci/fonts.conf +37 -0
  35. package/scripts/ci/goldens/baseline-40.png +0 -0
  36. package/scripts/ci/goldens/baseline-80.png +0 -0
  37. package/scripts/ci/goldens/cjk-80.png +0 -0
  38. package/scripts/ci/goldens/emoji-80.png +0 -0
  39. package/scripts/ci/render-statusline-pty.py +99 -0
  40. package/scripts/ci/render-vhs-goldens.sh +235 -0
  41. package/scripts/ci/statusline-fixture/buddy-state/config.json +1 -0
  42. package/scripts/ci/statusline-fixture/buddy-state/status.json +39 -0
  43. package/scripts/ci/statusline.tape +9 -0
  44. package/scripts/ci/vhs-run-statusline.sh +16 -0
  45. package/scripts/gen-emoji-widths.ts +41 -17
  46. package/scripts/paths.sh +3 -1
  47. package/server/art.ts +45 -13
  48. package/server/hooks/buddy-comment.ts +166 -17
  49. package/server/hooks/common.ts +33 -0
  50. package/server/hooks/file-type-react.ts +147 -0
  51. package/server/hooks/mood-react.ts +107 -0
  52. package/server/hooks/name-react.ts +98 -0
  53. package/server/hooks/react.ts +413 -0
  54. package/server/hooks/reaction-data.ts +2722 -0
  55. package/server/index.ts +67 -27
  56. package/server/path.ts +3 -0
  57. package/server/state.ts +68 -17
  58. package/skills/buddy/SKILL.md +1 -1
  59. package/statusline/buddy-status.sh +351 -59
  60. package/statusline/emoji-text.data +3 -0
  61. package/statusline/emoji-widths.data +2 -3
  62. package/statusline/substatus.sh +73 -10
  63. package/hooks/legacy/file-type-react.sh +0 -403
  64. package/hooks/legacy/mood-react.sh +0 -210
  65. package/hooks/legacy/name-react.sh +0 -178
  66. package/hooks/legacy/react.sh +0 -1364
  67. package/server/hooks/legacy-bash.ts +0 -34
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi (requires bun: https://bun.sh/install)",
8
- "version": "0.7.3"
8
+ "version": "0.8.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "coding-buddy",
13
13
  "source": "./",
14
14
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality. Requires bun runtime (https://bun.sh/install).",
15
- "version": "0.7.3",
15
+ "version": "0.8.0",
16
16
  "author": {
17
17
  "name": "ramarivera"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-buddy",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
5
5
  "author": {
6
6
  "name": "ramarivera"
package/README.md CHANGED
@@ -53,7 +53,7 @@
53
53
  <td align="center" width="25%">
54
54
  <h3>💬</h3>
55
55
  <b>Speech Bubbles</b><br>
56
- <sub>Your buddy comments on your code in real time. Invisible, contextual, alive.</sub>
56
+ <sub>End-of-turn reactions come from the model by default (MCP tool call). When that doesn't fire — older Claude Code, no MCP, a turn where the model didn't react — a Stop hook falls back to a tagged canned pool line so the bubble is never silent.</sub>
57
57
  </td>
58
58
  <td align="center" width="25%">
59
59
  <h3>⚡</h3>
@@ -95,6 +95,8 @@ The `claude-buddy` package ended at **v0.5.2**.
95
95
  | **Pi** | `mariozechner/pi-coding-agent` workspace | Stable random UUID persisted in `~/.pi/agent/buddy/identity.json` |
96
96
  | **Oh My Pi (OMP)** | OMP harness with `omp.extensions` support | Stable random UUID persisted in `~/.omp/agent/buddy/identity.json` |
97
97
 
98
+ All three surfaces use the same hero composition: the dashed speech bubble is on the left, its `--` tail points toward the rarity-colored sprite on the right, and the companion name is centered underneath the sprite. The card recomputes against the current terminal width on every render.
99
+
98
100
  ## 🚀 Quick Start
99
101
 
100
102
  ### Claude Code
@@ -135,6 +137,8 @@ bun run install-buddy
135
137
  <sub>💡 Need help? → `bun run help`, `npx -y @ramarivera/coding-buddy help`, or `coding-buddy help` (if linked) · `/buddy help` in Claude Code</sub>
136
138
 
137
139
 
140
+
141
+
138
142
  ### Multiple Claude profiles?
139
143
 
140
144
  If you run Claude Code with `CLAUDE_CONFIG_DIR` set (e.g. separate work and personal accounts), pass the same env var to install so buddy lands in the active profile and gets its own menagerie:
@@ -146,6 +150,16 @@ CLAUDE_CONFIG_DIR=~/.claude-personal npx -y @ramarivera/coding-buddy uninstall
146
150
 
147
151
  The installer prints `Target profile: <path>` at the top so you can see at a glance which profile you're targeting. Each profile gets its own MCP entry, skill, hooks, status line, and `$CLAUDE_CONFIG_DIR/buddy-state/` menagerie — installs in one profile don't touch another. With `CLAUDE_CONFIG_DIR` unset, behaviour is identical to single-profile (`~/.claude/`, `~/.claude-buddy/`).
148
152
 
153
+ ### One companion across harnesses
154
+
155
+ To opt into one identity and one menagerie across Claude Code, Pi, and OMP, set the same variable before launching each harness:
156
+
157
+ ```bash
158
+ export CODING_BUDDY_USER_ID="ramiro"
159
+ ```
160
+
161
+ This opt-in uses `~/.coding-buddy/shared` for the companion, XP, achievements, and reaction state. Set `CODING_BUDDY_STATE_DIR` when you need a different shared root. Sharing the state directory is intentional: it makes the companion actually follow you, at the cost of giving up separate per-harness progress. Without `CODING_BUDDY_USER_ID`, the existing Claude account identity and Pi/OMP per-harness `identity.json` behavior remain unchanged.
162
+
149
163
  <br>
150
164
 
151
165
  ---
@@ -159,7 +173,7 @@ The installer prints `Target profile: <path>` at the top so you can see at a gla
159
173
 
160
174
  <br>
161
175
 
162
- Every buddy is uniquely generated — same species, same stats, same personality every time. On **Claude Code** the seed is derived from your `accountUuid` in `~/.claude.json`; on **Pi** and **Oh My Pi** a stable random UUID is generated once and persisted in `~/.pi/agent/buddy/identity.json` or `~/.omp/agent/buddy/identity.json` (deleting the file and letting it regenerate will change your buddy). 19 species, each with 3 idle animation frames + a blink.
176
+ Every buddy is uniquely generated — same species, same stats, same personality every time. On **Claude Code** the seed is derived from your `accountUuid` in `~/.claude.json`; on **Pi** and **Oh My Pi** a stable random UUID is generated once and persisted in `~/.pi/agent/buddy/identity.json` or `~/.omp/agent/buddy/identity.json` (unless `CODING_BUDDY_USER_ID` is set). 19 species, each with 3 idle animation frames + a blink.
163
177
 
164
178
  <!-- Later replace with: docs/species-grid.png -->
165
179
  <p align="center">
@@ -233,11 +247,11 @@ Five integration points, zero binary dependencies. When Claude Code, Pi, or Oh M
233
247
  └─────────────────────┘
234
248
  ```
235
249
 
236
- - **MCP Server** — companion tools + system prompt that instructs Claude to write buddy comments
250
+ - **MCP Server** — companion tools + system prompt that tells Claude to call `buddy_react` at the end of every turn. The tool call writes the reaction with `source: "tool"` — model-authored, renders nowhere in the transcript.
237
251
  - **Skill** — routes `/buddy`, `/buddy pet`, `/buddy stats`, `/buddy off`, `/buddy rename`
238
252
  - **Status Line** — animated ASCII art, right-aligned, with rarity color and speech bubble
239
253
  - **PostToolUse Hook** — detects errors, test failures, large diffs in Bash output
240
- - **Stop Hook** — extracts invisible `<!-- buddy: ... -->` comments from Claude's responses
254
+ - **Stop Hook** — backward-compat fallback chain. Skips if a fresh `buddy_react` (primary channel) just ran; otherwise extracts a legacy `<!-- buddy: ... -->` comment if Claude happens to emit one (older Claude Code builds), and otherwise picks a canned pool line via `server/turn-reaction.ts`. Every write carries a `source` tag (`tool`/`comment`/`fallback`) so `/buddy stats` can prove what produced the bubble.
241
255
 
242
256
  ### Why MCP Instead of Binary Patching?
243
257
 
@@ -314,8 +328,13 @@ You can run any command with `npx -y @ramarivera/coding-buddy <command>` from an
314
328
  | `bun run disable` | Temporarily deactivate buddy |
315
329
  | `bun run enable` | Re-enable buddy |
316
330
  | `bun run help` | Full CLI reference |
331
+
317
332
  | `bun run uninstall` | Clean removal |
318
333
 
334
+ ### Statusline width and reaction lifetime
335
+
336
+ The statusline reserves 14 columns for Claude Code's content-box chrome before laying out the buddy (two columns for `padding: 1` and twelve for internal margins, calibrated against the reported ~74-column content box). If your harness uses different padding or margins, set the signed `statuslineWidthAdjust` value in `buddy-state/config.json` (for example, `-4` makes the usable width four columns narrower; `+4` widens it). Width is resolved in this order: `BUDDY_STATUSLINE_COLS`, exported `COLUMNS`, Linux `/proc` PTY, macOS `ps` + `stty`, PowerShell, then a final default of `125`; `statuslineWidthAdjust` is applied to the detected width. Both environment overrides must be positive integers to take effect. The width regression fixture renders at 60, 80, and 120 columns and asserts that every ANSI-stripped row fits the resulting budget. `reactionTTL` defaults to 900 seconds (15 minutes); set it to `0` only to keep reactions permanently. Expired per-session reaction and comment-throttle files are removed automatically, so stale bubbles don't accumulate. The final ANSI safety pass does one Unicode-width profile per row rather than spawning subprocesses per character.
337
+
319
338
  <sub>💡 Want a global `coding-buddy` command? → `cd coding-buddy && bun link`</sub>
320
339
 
321
340
  ### 🎯 Buddy Pick
@@ -16,7 +16,7 @@ export interface OmpBuddyContext extends OmpBuddyUiContext {
16
16
  model: ExtensionContext["model"];
17
17
  modelRegistry: Pick<
18
18
  ExtensionContext["modelRegistry"],
19
- "find" | "getApiKey" | "getProviderHeaders"
19
+ "find" | "getApiKeyForProvider" | "getProviderHeaders"
20
20
  >;
21
21
  sessionManager: Pick<ExtensionContext["sessionManager"], "getBranch">;
22
22
  }
@@ -12,7 +12,7 @@ import { complete, type AssistantMessage, type TextContent, type UserMessage } f
12
12
  import { BuddyCommandService, type ReactionResult } from "../../core/command-service.ts";
13
13
  import type { BuddyTurnCommentModelConfig, Companion } from "../../core/model.ts";
14
14
  import type { Achievement } from "../../core/achievements.ts";
15
- import { getNameReaction, getSuccessReaction, isNameMentioned } from "../../core/reactions.ts";
15
+ import { extractGeneratedPersonality, generatePersonalityPrompt, getNameReaction, getSuccessReaction, isNameMentioned } from "../../core/reactions.ts";
16
16
  import { OmpBuddyStorage } from "./storage.ts";
17
17
  import { buildBuddyReactionPrompt, buildBuddyReactionSystemPrompt, normalizeBuddyComment, stripBuddyComments } from "./prompt.ts";
18
18
  import { OmpBuddyUI } from "./ui.ts";
@@ -37,19 +37,31 @@ interface RegisterOmpBuddyEventsDeps {
37
37
  ui: OmpBuddyUI;
38
38
  logger: OmpBuddyLog;
39
39
  completeTurnComment?: TurnCommentCompleter;
40
+ completePersonality?: PersonalityCompleter;
40
41
  }
41
42
 
42
43
  export function registerOmpBuddyEvents(pi: ExtensionAPI, deps: RegisterOmpBuddyEventsDeps): void {
43
44
  pi.on("session_start", async (_event: SessionStartEvent, ctx: OmpBuddyContext) => {
44
45
  const result = deps.service.startSession();
46
+ let companion = result.companion;
47
+ if (result.created) {
48
+ const generated = await generatePersonality(ctx, companion, deps.logger, deps.completePersonality, {
49
+ modelOverride: deps.storage.loadOmpConfig().turnCommentModel,
50
+ });
51
+ if (generated) {
52
+ const updated = deps.service.setPersonalityForSlot(result.slot, generated, result.companion.personality);
53
+ if (updated) companion = updated;
54
+ }
55
+ }
56
+ companion = deps.storage.loadActive() ?? companion;
45
57
  deps.logger.info("session_start", {
46
- companion: result.companion.name,
47
- species: result.companion.bones.species,
58
+ companion: companion.name,
59
+ species: companion.bones.species,
48
60
  created: result.created,
49
61
  });
50
- deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
62
+ deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), result.achievements);
51
63
  if (result.created) {
52
- ctx.ui.notify(`A new buddy hatched: ${result.companion.name}`, "info");
64
+ ctx.ui.notify(`A new buddy hatched: ${companion.name}`, "info");
53
65
  }
54
66
  deps.ui.notifyAchievements(ctx, result.achievements);
55
67
  });
@@ -162,13 +174,17 @@ export function registerOmpBuddyEvents(pi: ExtensionAPI, deps: RegisterOmpBuddyE
162
174
  return;
163
175
  }
164
176
 
177
+ const config = deps.storage.loadOmpConfig();
165
178
  const generated = await generateTurnComment(
166
179
  ctx,
167
180
  progress.companion,
168
181
  event,
169
182
  deps.logger,
170
183
  deps.completeTurnComment ?? complete,
171
- deps.storage.loadOmpConfig().turnCommentModel,
184
+ {
185
+ modelOverride: config.turnCommentModel,
186
+ timeoutMs: config.turnCommentTimeoutMs,
187
+ },
172
188
  );
173
189
  if (!generated.comment) {
174
190
  const assistantLength = isAssistantMessage(event.message) ? getAssistantText(event.message).length : 0;
@@ -188,7 +204,7 @@ export function registerOmpBuddyEvents(pi: ExtensionAPI, deps: RegisterOmpBuddyE
188
204
  deps.ui.notifyAchievements(ctx, achievements);
189
205
  });
190
206
  pi.on("session_shutdown", () => {
191
- deps.ui.cancelTimers();
207
+ deps.ui.dispose();
192
208
  });
193
209
 
194
210
  }
@@ -294,14 +310,28 @@ function getAssistantText(message: AssistantMessage): string {
294
310
  .join("\n");
295
311
  }
296
312
 
313
+ /** Well under OMP's 30s handler budget; cosmetic reactions must never stall a turn. */
314
+ export const TURN_COMMENT_TIMEOUT_MS = 5_000;
315
+
316
+ export function resolveTurnCommentTimeoutMs(timeoutMs: number | undefined): number {
317
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
318
+ ? timeoutMs
319
+ : TURN_COMMENT_TIMEOUT_MS;
320
+ }
321
+
297
322
  export async function generateTurnComment(
298
323
  ctx: OmpBuddyContext,
299
324
  companion: Companion,
300
325
  event: TurnEndEvent,
301
326
  logger: OmpBuddyLog,
302
327
  completeTurnComment: TurnCommentCompleter = complete,
303
- modelOverride?: BuddyTurnCommentModelConfig,
328
+ options: {
329
+ modelOverride?: BuddyTurnCommentModelConfig;
330
+ timeoutMs?: number;
331
+ } = {},
304
332
  ): Promise<{ comment: string | null; source: "llm" | "fallback" | "none" }> {
333
+ const { modelOverride } = options;
334
+ const timeoutMs = resolveTurnCommentTimeoutMs(options.timeoutMs);
305
335
  const assistantText = isAssistantMessage(event.message) ? getAssistantText(event.message) : "";
306
336
  if (!assistantText.trim()) {
307
337
  logger.warn("turn_comment_skipped", { reason: "empty_assistant_text" });
@@ -309,97 +339,151 @@ export async function generateTurnComment(
309
339
  }
310
340
 
311
341
  const turnCommentModel = resolveTurnCommentModel(ctx, logger, modelOverride);
312
- if (turnCommentModel) {
313
- const toolResultsText = getToolResultsText(event);
314
- const userText = getUserPromptText(ctx);
315
- const systemPrompt = buildBuddyReactionSystemPrompt(companion);
316
- const promptText = buildBuddyReactionPrompt(companion, assistantText, toolResultsText, userText);
317
- logger.info("turn_comment_llm_attempt", {
318
- modelProvider: turnCommentModel.provider,
319
- modelId: turnCommentModel.id,
342
+ if (!turnCommentModel) {
343
+ logger.warn("turn_comment_llm_skipped", { reason: "no_model" });
344
+ const fallback = deriveTurnComment(companion, event.message);
345
+ logger.warn("turn_comment_fallback", {
346
+ fallbackLength: fallback?.length ?? 0,
320
347
  assistantLength: assistantText.length,
321
- toolLength: toolResultsText.length,
322
- userTextLength: userText.length,
323
- promptLength: promptText.length,
324
- systemPromptLength: systemPrompt.length,
325
- toolResultCount: event.toolResults.length,
326
348
  });
327
- const promptDebugData: Record<string, unknown> = {
328
- assistantLength: assistantText.length,
329
- toolLength: toolResultsText.length,
330
- userTextLength: userText.length,
331
- };
332
- if (diagnosticPreviewsEnabled()) {
333
- promptDebugData.systemPromptPreview = systemPrompt.slice(0, 800);
334
- promptDebugData.promptPreview = promptText.slice(0, 1200);
335
- promptDebugData.userTextPreview = userText.slice(0, 200);
336
- promptDebugData.assistantPreview = assistantText.slice(0, 200);
337
- promptDebugData.toolPreview = toolResultsText.slice(0, 200);
338
- }
339
- logger.debug("turn_comment_llm_prompt", promptDebugData);
349
+ return { comment: fallback, source: fallback ? "fallback" : "none" };
350
+ }
351
+
352
+ const toolResultsText = getToolResultsText(event);
353
+ const userText = getUserPromptText(ctx);
354
+ const systemPrompt = buildBuddyReactionSystemPrompt(companion);
355
+ const promptText = buildBuddyReactionPrompt(companion, assistantText, toolResultsText, userText);
356
+ logger.info("turn_comment_llm_attempt", {
357
+ modelProvider: turnCommentModel.provider,
358
+ modelId: turnCommentModel.id,
359
+ assistantLength: assistantText.length,
360
+ toolLength: toolResultsText.length,
361
+ userTextLength: userText.length,
362
+ promptLength: promptText.length,
363
+ systemPromptLength: systemPrompt.length,
364
+ toolResultCount: event.toolResults.length,
365
+ timeoutMs,
366
+ });
367
+ const promptDebugData: Record<string, unknown> = {
368
+ assistantLength: assistantText.length,
369
+ toolLength: toolResultsText.length,
370
+ userTextLength: userText.length,
371
+ };
372
+ if (diagnosticPreviewsEnabled()) {
373
+ promptDebugData.systemPromptPreview = systemPrompt.slice(0, 800);
374
+ promptDebugData.promptPreview = promptText.slice(0, 1200);
375
+ promptDebugData.userTextPreview = userText.slice(0, 200);
376
+ promptDebugData.assistantPreview = assistantText.slice(0, 200);
377
+ promptDebugData.toolPreview = toolResultsText.slice(0, 200);
378
+ }
379
+ logger.debug("turn_comment_llm_prompt", promptDebugData);
380
+
381
+ const userMessage: UserMessage = {
382
+ role: "user",
383
+ content: [{ type: "text", text: promptText }],
384
+ timestamp: Date.now(),
385
+ };
386
+
387
+ const controller = new AbortController();
388
+ let timedOut = false;
389
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
390
+ const timeoutPromise = new Promise<never>((_, reject) => {
391
+ timeoutId = setTimeout(() => {
392
+ timedOut = true;
393
+ controller.abort(new Error("Turn comment generation timed out"));
394
+ reject(new Error("Turn comment generation timed out"));
395
+ }, timeoutMs);
396
+ });
397
+
398
+ const workPromise = (async (): Promise<string | null> => {
340
399
  let apiKey: string | undefined;
341
400
  try {
342
- apiKey = await ctx.modelRegistry.getApiKey(turnCommentModel);
401
+ apiKey = await ctx.modelRegistry.getApiKeyForProvider(
402
+ turnCommentModel.provider,
403
+ undefined,
404
+ {
405
+ baseUrl: turnCommentModel.baseUrl,
406
+ modelId: turnCommentModel.id,
407
+ signal: controller.signal,
408
+ },
409
+ );
343
410
  } catch (error) {
344
411
  logger.warn("turn_comment_auth_unavailable", {
345
412
  message: error instanceof Error ? error.message : String(error),
346
413
  });
414
+ return null;
347
415
  }
416
+
348
417
  const headers = ctx.modelRegistry.getProviderHeaders(turnCommentModel.provider);
349
418
  logger.debug("turn_comment_auth", {
350
419
  hasApiKey: Boolean(apiKey),
351
420
  headerKeys: Object.keys(headers ?? {}),
352
421
  });
353
- if (apiKey) {
354
- const userMessage: UserMessage = {
355
- role: "user",
356
- content: [{ type: "text", text: promptText }],
357
- timestamp: Date.now(),
358
- };
359
-
360
- try {
361
- const response = await completeTurnComment(
362
- turnCommentModel,
363
- {
364
- systemPrompt: [systemPrompt],
365
- messages: [userMessage],
366
- },
367
- {
368
- apiKey,
369
- headers,
370
- },
371
- );
372
-
373
- if (response.stopReason !== "aborted") {
374
- const text = response.content
375
- .filter((block): block is TextContent => block.type === "text")
376
- .map((block) => block.text)
377
- .join("\n");
378
- const normalized = normalizeBuddyComment(text);
379
- logger.info("turn_comment_llm_result", {
380
- stopReason: response.stopReason,
381
- errorMessage: "errorMessage" in response ? response.errorMessage : undefined,
382
- contentTypes: response.content.map((block) => block.type),
383
- contentCount: response.content.length,
384
- rawLength: text.length,
385
- normalizedLength: normalized.length,
386
- });
387
- if (normalized) return { comment: normalized, source: "llm" };
388
- logger.warn("turn_comment_llm_empty", { rawLength: text.length });
389
- }
390
- } catch (error) {
391
- logger.error("turn_comment_llm_error", {
392
- message: error instanceof Error ? error.message : String(error),
393
- stack: error instanceof Error ? error.stack : undefined,
394
- });
395
- }
396
- } else {
422
+ if (!apiKey) {
397
423
  logger.warn("turn_comment_auth_unavailable", {
398
424
  message: "missing api key",
399
425
  });
426
+ return null;
427
+ }
428
+
429
+ const response = await completeTurnComment(
430
+ turnCommentModel,
431
+ {
432
+ systemPrompt: [systemPrompt],
433
+ messages: [userMessage],
434
+ },
435
+ {
436
+ apiKey,
437
+ headers,
438
+ signal: controller.signal,
439
+ },
440
+ );
441
+
442
+ if (response.stopReason === "aborted") {
443
+ logger.warn("turn_comment_llm_aborted", {
444
+ errorMessage: "errorMessage" in response ? response.errorMessage : undefined,
445
+ });
446
+ return null;
447
+ }
448
+
449
+ const text = response.content
450
+ .filter((block): block is TextContent => block.type === "text")
451
+ .map((block) => block.text)
452
+ .join("\n");
453
+ const normalized = normalizeBuddyComment(text);
454
+ logger.info("turn_comment_llm_result", {
455
+ stopReason: response.stopReason,
456
+ errorMessage: "errorMessage" in response ? response.errorMessage : undefined,
457
+ contentTypes: response.content.map((block) => block.type),
458
+ contentCount: response.content.length,
459
+ rawLength: text.length,
460
+ normalizedLength: normalized.length,
461
+ });
462
+ if (normalized) return normalized;
463
+ logger.warn("turn_comment_llm_empty", { rawLength: text.length });
464
+ return null;
465
+ })();
466
+
467
+ // Prevent unhandled rejection if timeout wins the race first.
468
+ workPromise.catch(() => {});
469
+
470
+ try {
471
+ const comment = await Promise.race([workPromise, timeoutPromise]);
472
+ clearTimeout(timeoutId);
473
+ if (comment) return { comment, source: "llm" };
474
+ } catch (error) {
475
+ clearTimeout(timeoutId);
476
+ if (timedOut) {
477
+ logger.warn("turn_comment_llm_timeout", {
478
+ timeoutMs,
479
+ message: error instanceof Error ? error.message : String(error),
480
+ });
481
+ } else {
482
+ logger.error("turn_comment_llm_error", {
483
+ message: error instanceof Error ? error.message : String(error),
484
+ stack: error instanceof Error ? error.stack : undefined,
485
+ });
400
486
  }
401
- } else {
402
- logger.warn("turn_comment_llm_skipped", { reason: "no_model" });
403
487
  }
404
488
 
405
489
  const fallback = deriveTurnComment(companion, event.message);
@@ -409,6 +493,127 @@ export async function generateTurnComment(
409
493
  });
410
494
  return { comment: fallback, source: fallback ? "fallback" : "none" };
411
495
  }
496
+ const PERSONALITY_GENERATION_TIMEOUT_MS = 10_000;
497
+
498
+ export type PersonalityCompleter = (
499
+ model: Parameters<typeof complete>[0],
500
+ context: Parameters<typeof complete>[1],
501
+ options: Parameters<typeof complete>[2],
502
+ ) => Promise<AssistantMessage>;
503
+ export async function generatePersonality(
504
+ ctx: OmpBuddyContext,
505
+ companion: Companion,
506
+ logger: OmpBuddyLog,
507
+ completePersonality: PersonalityCompleter = complete,
508
+ options: {
509
+ modelOverride?: BuddyTurnCommentModelConfig;
510
+ timeoutMs?: number;
511
+ } = {},
512
+ ): Promise<string | null> {
513
+ const { modelOverride, timeoutMs = PERSONALITY_GENERATION_TIMEOUT_MS } = options;
514
+ const model = resolveTurnCommentModel(ctx, logger, modelOverride);
515
+ if (!model) {
516
+ logger.warn("personality_generation_skipped", { reason: "no_model" });
517
+ return null;
518
+ }
519
+
520
+ const promptText = generatePersonalityPrompt(
521
+ companion.bones.species,
522
+ companion.bones.rarity,
523
+ { ...companion.bones.stats } as Record<string, number>,
524
+ companion.bones.shiny,
525
+ );
526
+ const userMessage: UserMessage = {
527
+ role: "user",
528
+ content: [{ type: "text", text: promptText }],
529
+ timestamp: Date.now(),
530
+ };
531
+
532
+ const controller = new AbortController();
533
+ let timedOut = false;
534
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
535
+ const timeoutPromise = new Promise<never>((_, reject) => {
536
+ timeoutId = setTimeout(() => {
537
+ timedOut = true;
538
+ controller.abort(new Error("Personality generation timed out"));
539
+ reject(new Error("Personality generation timed out"));
540
+ }, timeoutMs);
541
+ });
542
+
543
+ const workPromise = (async (): Promise<string | null> => {
544
+ let apiKey: string | undefined;
545
+ try {
546
+ apiKey = await ctx.modelRegistry.getApiKeyForProvider(
547
+ model.provider,
548
+ undefined,
549
+ { baseUrl: model.baseUrl, modelId: model.id, signal: controller.signal },
550
+ );
551
+ } catch (error) {
552
+ logger.warn("personality_generation_auth_unavailable", {
553
+ message: error instanceof Error ? error.message : String(error),
554
+ });
555
+ }
556
+ const headers = ctx.modelRegistry.getProviderHeaders(model.provider);
557
+ logger.debug("personality_generation_auth", {
558
+ hasApiKey: Boolean(apiKey),
559
+ headerKeys: Object.keys(headers ?? {}),
560
+ });
561
+ if (!apiKey) {
562
+ logger.warn("personality_generation_auth_unavailable", { message: "missing api key" });
563
+ return null;
564
+ }
565
+
566
+ const response = await completePersonality(
567
+ model,
568
+ { messages: [userMessage] },
569
+ { apiKey, headers, signal: controller.signal },
570
+ );
571
+
572
+ if (response.stopReason === "aborted") {
573
+ logger.warn("personality_generation_aborted", {
574
+ errorMessage: response.errorMessage,
575
+ });
576
+ return null;
577
+ }
578
+
579
+ const text = response.content
580
+ .filter((block): block is TextContent => block.type === "text")
581
+ .map((block) => block.text)
582
+ .join("\n");
583
+ const personality = extractGeneratedPersonality(text);
584
+ logger.info("personality_generation_result", {
585
+ stopReason: response.stopReason,
586
+ rawLength: text.length,
587
+ hasPersonality: !!personality,
588
+ });
589
+ if (personality) return personality;
590
+ logger.warn("personality_generation_malformed", { rawLength: text.length });
591
+ return null;
592
+ })();
593
+
594
+ workPromise.catch(() => {});
595
+
596
+ let result: string | null;
597
+ try {
598
+ result = await Promise.race([workPromise, timeoutPromise]);
599
+ clearTimeout(timeoutId);
600
+ } catch (error) {
601
+ clearTimeout(timeoutId);
602
+ if (timedOut) {
603
+ logger.warn("personality_generation_timeout", {
604
+ message: error instanceof Error ? error.message : String(error),
605
+ });
606
+ } else {
607
+ logger.error("personality_generation_error", {
608
+ message: error instanceof Error ? error.message : String(error),
609
+ stack: error instanceof Error ? error.stack : undefined,
610
+ });
611
+ }
612
+ result = null;
613
+ }
614
+
615
+ return result;
616
+ }
412
617
 
413
618
  export function resolveTurnCommentModel(
414
619
  ctx: OmpBuddyContext,
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
2
  import { BuddyCommandService } from "../../core/command-service.ts";
3
3
  import { registerBuddyCommands } from "./commands.ts";
4
- import { registerOmpBuddyEvents, type TurnCommentCompleter } from "./events.ts";
4
+ import { registerOmpBuddyEvents, type PersonalityCompleter, type TurnCommentCompleter } from "./events.ts";
5
5
  import { OmpIdentityProvider } from "./identity.ts";
6
6
  import { OmpBuddyLogger } from "./logger.ts";
7
7
  import { OmpBuddyStorage } from "./storage.ts";
@@ -10,6 +10,7 @@ import { OmpBuddyUI } from "./ui.ts";
10
10
  export interface OmpBuddyExtensionOptions {
11
11
  stateDir?: string;
12
12
  completeTurnComment?: TurnCommentCompleter;
13
+ completePersonality?: PersonalityCompleter;
13
14
  }
14
15
 
15
16
  export default function registerOmpBuddyExtension(
@@ -35,5 +36,6 @@ export default function registerOmpBuddyExtension(
35
36
  ui,
36
37
  logger,
37
38
  completeTurnComment: options.completeTurnComment,
39
+ completePersonality: options.completePersonality,
38
40
  });
39
41
  }
@@ -1,61 +1,14 @@
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";
2
+ import type { Companion, ReactionState } from "../../core/model.ts";
6
3
  import {
7
- composeDetailsAndArt,
8
4
  displayWidth,
9
- getDetailsWidth,
10
5
  getWidgetWidth,
6
+ renderCompanionWidget,
11
7
  stripAnsi,
12
8
  WIDGET_MAX_LINES,
13
- wrapReaction,
14
9
  } from "../shared/widget-layout.ts";
15
10
 
16
- const RESET = "\x1b[0m";
17
- const DIM_ITALIC = "\x1b[2;3m";
18
-
19
- export { displayWidth, WIDGET_MAX_LINES as OMP_WIDGET_MAX_LINES };
20
-
21
- function trimArt(line: string): string {
22
- return line.replace(/\s+$/g, "");
23
- }
24
-
25
- function getFullArtFrame(companion: Companion, frame: number): string[] {
26
- const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
27
- if (companion.bones.hat !== "none" && !stripAnsi(art[0] ?? "").trim()) {
28
- art[0] = HAT_ART[companion.bones.hat];
29
- }
30
- return art.map(trimArt);
31
- }
32
-
33
- function renderDetails(
34
- companion: Companion,
35
- reaction: ReactionState | null | undefined,
36
- achievements: Achievement[],
37
- sideWidth: number,
38
- ): string[] {
39
- const color = getRarityColor(companion.bones.rarity);
40
- const stars = RARITY_STARS[companion.bones.rarity];
41
- const shiny = companion.bones.shiny ? " ✨" : "";
42
- const lines = [
43
- `${color}${companion.name} ${stars}${shiny}${RESET}`,
44
- `${companion.bones.rarity} ${companion.bones.species}`,
45
- ];
46
-
47
- if (reaction?.reaction) {
48
- const remainingLines = Math.max(1, WIDGET_MAX_LINES - lines.length);
49
- lines.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
50
- }
51
-
52
- for (const achievement of achievements) {
53
- if (lines.length >= WIDGET_MAX_LINES) break;
54
- lines.push(`🏆 ${achievement.name}`);
55
- }
56
-
57
- return lines;
58
- }
11
+ export { displayWidth, stripAnsi, WIDGET_MAX_LINES as OMP_WIDGET_MAX_LINES };
59
12
 
60
13
  export function renderBuddyWidget(
61
14
  companion: Companion,
@@ -63,10 +16,7 @@ export function renderBuddyWidget(
63
16
  achievements: Achievement[] = [],
64
17
  width: number = getWidgetWidth(),
65
18
  ): string[] {
66
- const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
67
- const sideWidth = getDetailsWidth(art, width);
68
- const details = renderDetails(companion, reaction, achievements, sideWidth);
69
- return composeDetailsAndArt(details, art, width, WIDGET_MAX_LINES);
19
+ return renderCompanionWidget(companion, reaction, achievements, width);
70
20
  }
71
21
 
72
22
  export function renderBuddyStats(companion: Companion): string[] {