@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/adapters/omp/ui.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { OmpBuddyUiContext } from "./context.ts";
|
|
2
2
|
import type { Achievement } from "../../core/achievements.ts";
|
|
3
3
|
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
|
-
import { renderAchievementsSummary
|
|
4
|
+
import { renderAchievementsSummary } from "./renderers.ts";
|
|
5
5
|
import { OmpBuddyStorage } from "./storage.ts";
|
|
6
|
+
import { BuddyWidget } 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 readonly buddy = new BuddyWidget();
|
|
9
11
|
|
|
10
12
|
constructor(private readonly storage: OmpBuddyStorage) {}
|
|
11
13
|
|
|
@@ -19,6 +21,11 @@ export class OmpBuddyUI {
|
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
dispose(): void {
|
|
25
|
+
this.cancelTimers();
|
|
26
|
+
this.buddy.dispose();
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
refresh(
|
|
23
30
|
ctx: OmpBuddyUiContext,
|
|
24
31
|
companion: Companion,
|
|
@@ -27,10 +34,15 @@ export class OmpBuddyUI {
|
|
|
27
34
|
): void {
|
|
28
35
|
const currentReaction = reaction ?? null;
|
|
29
36
|
const muted = this.storage.isMuted();
|
|
37
|
+
const scheduler = getScheduler(ctx);
|
|
38
|
+
|
|
30
39
|
ctx.ui.setStatus("buddy", undefined);
|
|
31
|
-
|
|
32
|
-
"buddy",
|
|
33
|
-
|
|
40
|
+
this.buddy.refresh(
|
|
41
|
+
(lines) => ctx.ui.setWidget("buddy", lines),
|
|
42
|
+
companion,
|
|
43
|
+
muted ? null : currentReaction,
|
|
44
|
+
[...achievements],
|
|
45
|
+
scheduler ?? undefined,
|
|
34
46
|
);
|
|
35
47
|
|
|
36
48
|
this.cancelTimers();
|
|
@@ -38,8 +50,6 @@ export class OmpBuddyUI {
|
|
|
38
50
|
|
|
39
51
|
const ttl = this.storage.loadConfig().reactionTTL;
|
|
40
52
|
if (ttl <= 0) return;
|
|
41
|
-
|
|
42
|
-
const scheduler = getScheduler(ctx);
|
|
43
53
|
if (!scheduler) return;
|
|
44
54
|
|
|
45
55
|
const reactionTimestamp = currentReaction.timestamp;
|
|
@@ -66,6 +76,7 @@ export class OmpBuddyUI {
|
|
|
66
76
|
unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
|
|
67
77
|
remaining: Achievement[],
|
|
68
78
|
): void {
|
|
79
|
+
this.buddy.clear();
|
|
69
80
|
ctx.ui.setWidget("buddy", renderAchievementsSummary(unlocked, remaining));
|
|
70
81
|
ctx.ui.notify(`Achievements: ${unlocked.length} unlocked`, "info");
|
|
71
82
|
}
|
package/adapters/pi/events.ts
CHANGED
|
@@ -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:
|
|
39
|
-
species:
|
|
51
|
+
companion: companion.name,
|
|
52
|
+
species: companion.bones.species,
|
|
40
53
|
created: result.created,
|
|
41
54
|
});
|
|
42
|
-
deps.ui.refresh(ctx,
|
|
55
|
+
deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), result.achievements);
|
|
43
56
|
if (result.created) {
|
|
44
|
-
ctx.ui.notify(`A new buddy hatched: ${
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
|
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"];
|
package/adapters/pi/index.ts
CHANGED
|
@@ -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, {
|
|
33
|
+
registerBuddyEvents(pi, {
|
|
34
|
+
service,
|
|
35
|
+
storage,
|
|
36
|
+
ui,
|
|
37
|
+
logger,
|
|
38
|
+
completeTurnComment: options.completeTurnComment,
|
|
39
|
+
completePersonality: options.completePersonality,
|
|
40
|
+
});
|
|
32
41
|
}
|
package/adapters/pi/renderers.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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[] {
|
package/adapters/pi/storage.ts
CHANGED
|
@@ -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 =
|
|
17
|
+
constructor(stateDir = defaultPiBuddyStateDir()) {
|
|
13
18
|
super(stateDir);
|
|
14
19
|
}
|
|
15
20
|
|
package/adapters/pi/ui.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import type { Achievement } from "../../core/achievements.ts";
|
|
3
3
|
import type { Companion, ReactionState } from "../../core/model.ts";
|
|
4
|
-
import { renderAchievementsSummary
|
|
4
|
+
import { renderAchievementsSummary } from "./renderers.ts";
|
|
5
5
|
import { PiBuddyStorage } from "./storage.ts";
|
|
6
|
+
import { BuddyWidget } from "../shared/widget-layout.ts";
|
|
6
7
|
|
|
7
8
|
type PiBuddyUiContext = ExtensionContext & {
|
|
8
9
|
setTimeout?: (callback: () => void, ms?: number) => Timer;
|
|
@@ -11,6 +12,7 @@ type PiBuddyUiContext = ExtensionContext & {
|
|
|
11
12
|
|
|
12
13
|
export class PiBuddyUI {
|
|
13
14
|
private readonly timers: Array<{ timer: Timer; clear: (timer: Timer) => void }> = [];
|
|
15
|
+
private readonly buddy = new BuddyWidget();
|
|
14
16
|
|
|
15
17
|
constructor(private readonly storage: PiBuddyStorage) {}
|
|
16
18
|
|
|
@@ -24,6 +26,11 @@ export class PiBuddyUI {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
dispose(): void {
|
|
30
|
+
this.cancelTimers();
|
|
31
|
+
this.buddy.dispose();
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
refresh(
|
|
28
35
|
ctx: PiBuddyUiContext,
|
|
29
36
|
companion: Companion,
|
|
@@ -32,10 +39,15 @@ export class PiBuddyUI {
|
|
|
32
39
|
): void {
|
|
33
40
|
const currentReaction = reaction ?? null;
|
|
34
41
|
const muted = this.storage.isMuted();
|
|
42
|
+
const scheduler = getScheduler(ctx);
|
|
43
|
+
|
|
35
44
|
ctx.ui.setStatus("buddy", undefined);
|
|
36
|
-
|
|
37
|
-
"buddy",
|
|
38
|
-
|
|
45
|
+
this.buddy.refresh(
|
|
46
|
+
(lines) => ctx.ui.setWidget("buddy", lines),
|
|
47
|
+
companion,
|
|
48
|
+
muted ? null : currentReaction,
|
|
49
|
+
[...achievements],
|
|
50
|
+
scheduler ?? undefined,
|
|
39
51
|
);
|
|
40
52
|
|
|
41
53
|
this.cancelTimers();
|
|
@@ -43,8 +55,6 @@ export class PiBuddyUI {
|
|
|
43
55
|
|
|
44
56
|
const ttl = this.storage.loadConfig().reactionTTL;
|
|
45
57
|
if (ttl <= 0) return;
|
|
46
|
-
|
|
47
|
-
const scheduler = getScheduler(ctx);
|
|
48
58
|
if (!scheduler) return;
|
|
49
59
|
|
|
50
60
|
const reactionTimestamp = currentReaction.timestamp;
|
|
@@ -71,6 +81,7 @@ export class PiBuddyUI {
|
|
|
71
81
|
unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
|
|
72
82
|
remaining: Achievement[],
|
|
73
83
|
): void {
|
|
84
|
+
this.buddy.clear();
|
|
74
85
|
ctx.ui.setWidget("buddy", renderAchievementsSummary(unlocked, remaining));
|
|
75
86
|
ctx.ui.notify(`Achievements: ${unlocked.length} unlocked`, "info");
|
|
76
87
|
}
|