@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.
- package/README.md +23 -4
- package/adapters/omp/context.ts +1 -1
- package/adapters/omp/events.ts +286 -81
- package/adapters/omp/index.ts +3 -1
- package/adapters/omp/renderers.ts +4 -54
- package/adapters/omp/storage.ts +6 -1
- package/adapters/omp/ui.ts +17 -6
- package/adapters/pi/events.ts +280 -83
- package/adapters/pi/index.ts +11 -2
- package/adapters/pi/renderers.ts +3 -47
- package/adapters/pi/storage.ts +6 -1
- package/adapters/pi/ui.ts +17 -6
- package/adapters/shared/file-storage.ts +22 -0
- package/adapters/shared/widget-layout.ts +248 -2
- package/cli/settings.ts +1 -1
- package/cli/tui.tsx +1 -1
- package/core/command-service.ts +13 -0
- package/core/engine.ts +11 -2
- package/core/identity.ts +15 -0
- package/core/model.ts +3 -0
- package/core/ports.ts +4 -0
- package/core/reactions.ts +29 -2
- package/core/render-model.ts +4 -3
- package/package.json +6 -3
- package/scripts/ci/Dockerfile.vhs +125 -0
- package/scripts/ci/check-statusline.sh +441 -14
- package/scripts/ci/check-vhs-goldens.sh +97 -0
- package/scripts/ci/fonts.conf +37 -0
- package/scripts/ci/goldens/baseline-40.png +0 -0
- package/scripts/ci/goldens/baseline-80.png +0 -0
- package/scripts/ci/goldens/cjk-80.png +0 -0
- package/scripts/ci/goldens/emoji-80.png +0 -0
- package/scripts/ci/render-statusline-pty.py +107 -0
- package/scripts/ci/render-vhs-goldens.sh +226 -0
- package/scripts/ci/statusline.tape +28 -2
- package/scripts/ci/vhs-density-fixtures.sh +27 -0
- package/scripts/ci/vhs-run-statusline.sh +28 -0
- package/scripts/ci/write-density-status.ts +86 -0
- package/scripts/gen-emoji-widths.ts +41 -17
- package/scripts/paths.sh +3 -1
- package/server/art.ts +70 -17
- package/server/hooks/buddy-comment.ts +166 -17
- package/server/hooks/file-type-react.ts +1 -1
- package/server/hooks/mood-react.ts +1 -1
- package/server/hooks/name-react.ts +1 -1
- package/server/hooks/react.ts +1 -1
- package/server/hooks/reaction-data.ts +10 -3
- package/server/index.ts +67 -27
- package/server/path.ts +3 -0
- package/server/state.ts +147 -41
- package/skills/buddy/SKILL.md +1 -1
- package/statusline/buddy-status.sh +488 -76
- package/statusline/emoji-text.data +3 -0
- package/statusline/emoji-widths.data +2 -3
- package/statusline/substatus.sh +52 -10
- package/scripts/postinstall.cjs +0 -42
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>
|
|
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` (
|
|
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
|
|
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
|
|
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
|
package/adapters/omp/context.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface OmpBuddyContext extends OmpBuddyUiContext {
|
|
|
16
16
|
model: ExtensionContext["model"];
|
|
17
17
|
modelRegistry: Pick<
|
|
18
18
|
ExtensionContext["modelRegistry"],
|
|
19
|
-
"find" | "
|
|
19
|
+
"find" | "getApiKeyForProvider" | "getProviderHeaders"
|
|
20
20
|
>;
|
|
21
21
|
sessionManager: Pick<ExtensionContext["sessionManager"], "getBranch">;
|
|
22
22
|
}
|
package/adapters/omp/events.ts
CHANGED
|
@@ -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:
|
|
47
|
-
species:
|
|
58
|
+
companion: companion.name,
|
|
59
|
+
species: companion.bones.species,
|
|
48
60
|
created: result.created,
|
|
49
61
|
});
|
|
50
|
-
deps.ui.refresh(ctx,
|
|
62
|
+
deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), result.achievements);
|
|
51
63
|
if (result.created) {
|
|
52
|
-
ctx.ui.notify(`A new buddy hatched: ${
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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.
|
|
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,
|
package/adapters/omp/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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[] {
|
package/adapters/omp/storage.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getAgentDir } from "@oh-my-pi/pi-utils";
|
|
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_OMP_BUDDY_STATE_DIR = join(getAgentDir(), "buddy");
|
|
10
11
|
|
|
12
|
+
function defaultOmpBuddyStateDir(): string {
|
|
13
|
+
return sharedStateDir() ?? DEFAULT_OMP_BUDDY_STATE_DIR;
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export class OmpBuddyStorage extends FileBuddyStorage {
|
|
12
|
-
constructor(stateDir =
|
|
17
|
+
constructor(stateDir = defaultOmpBuddyStateDir()) {
|
|
13
18
|
super(stateDir);
|
|
14
19
|
}
|
|
15
20
|
|