@ramarivera/coding-buddy 0.4.0-alpha.8 → 0.6.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 (92) hide show
  1. package/{adapters/claude/plugin → .claude-plugin}/marketplace.json +11 -6
  2. package/{adapters/claude/plugin → .claude-plugin}/plugin.json +9 -7
  3. package/LICENSE +1 -1
  4. package/README.md +102 -72
  5. package/adapters/omp/commands.ts +201 -0
  6. package/adapters/omp/context.ts +22 -0
  7. package/adapters/omp/events.ts +551 -0
  8. package/adapters/omp/identity.ts +10 -0
  9. package/adapters/omp/index.ts +39 -0
  10. package/adapters/omp/logger.ts +52 -0
  11. package/adapters/omp/prompt.ts +60 -0
  12. package/adapters/omp/renderers.ts +73 -0
  13. package/adapters/omp/storage.ts +25 -0
  14. package/adapters/omp/ui.ts +85 -0
  15. package/adapters/pi/commands.ts +64 -37
  16. package/adapters/pi/events.ts +147 -72
  17. package/adapters/pi/index.ts +9 -4
  18. package/adapters/pi/storage.ts +14 -285
  19. package/adapters/pi/ui.ts +56 -5
  20. package/adapters/shared/file-storage.ts +526 -0
  21. package/{adapters/claude/install → cli}/backup.ts +59 -44
  22. package/cli/biomes.ts +309 -0
  23. package/cli/buddy-shell.ts +818 -0
  24. package/{adapters/claude/install → cli}/disable.ts +14 -32
  25. package/{adapters/claude/install → cli}/doctor.ts +55 -57
  26. package/{adapters/claude/install → cli}/hunt.ts +7 -9
  27. package/cli/index.ts +36 -25
  28. package/{adapters/claude/install → cli}/install.ts +100 -145
  29. package/{adapters/claude/install → cli}/pick.ts +111 -50
  30. package/{adapters/claude/install → cli}/settings.ts +2 -2
  31. package/{adapters/claude/install → cli}/show.ts +4 -4
  32. package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
  33. package/{adapters/claude/install → cli}/test-statusline.ts +11 -11
  34. package/cli/tui.tsx +2333 -0
  35. package/{adapters/claude/install → cli}/uninstall.ts +23 -38
  36. package/cli/upgrade.ts +213 -0
  37. package/cli/validate-species.ts +209 -0
  38. package/cli/verify.ts +3 -3
  39. package/core/achievements.ts +12 -20
  40. package/core/art-data.ts +26 -0
  41. package/core/command-service.ts +99 -97
  42. package/core/engine.ts +83 -1
  43. package/core/ports.ts +10 -0
  44. package/core/reactions.ts +533 -95
  45. package/core/slot-slug.ts +22 -0
  46. package/{adapters/claude/hooks → hooks}/buddy-comment.sh +23 -2
  47. package/hooks/file-type-react.sh +401 -0
  48. package/hooks/hooks.json +52 -0
  49. package/hooks/mood-react.sh +208 -0
  50. package/{adapters/claude/hooks → hooks}/name-react.sh +5 -2
  51. package/hooks/react.sh +1368 -0
  52. package/hooks/suggest.sh +55 -0
  53. package/package.json +113 -59
  54. package/scripts/gen-emoji-widths.ts +34 -0
  55. package/scripts/paths.sh +56 -0
  56. package/scripts/postinstall.cjs +42 -0
  57. package/scripts/snapshot-statusline.sh +54 -0
  58. package/server/achievements.ts +1419 -0
  59. package/server/art.ts +450 -0
  60. package/server/award-xp.ts +48 -0
  61. package/server/check-suggestions.ts +102 -0
  62. package/server/consolidate.ts +33 -0
  63. package/server/index.ts +1340 -0
  64. package/server/mcp-launcher.sh +26 -0
  65. package/server/memory.ts +404 -0
  66. package/server/mood.ts +254 -0
  67. package/server/path.ts +82 -0
  68. package/server/shift-mood.ts +43 -0
  69. package/{adapters/claude/storage → server}/state.ts +200 -7
  70. package/server/suggestions.ts +335 -0
  71. package/server/theme.ts +53 -0
  72. package/server/xp.ts +435 -0
  73. package/{adapters/claude/skills → skills}/buddy/SKILL.md +48 -4
  74. package/statusline/buddy-status.sh +397 -0
  75. package/statusline/combined-status.sh +134 -0
  76. package/statusline/emoji-widths.data +4 -0
  77. package/adapters/claude/hooks/hooks.json +0 -35
  78. package/adapters/claude/hooks/react.sh +0 -204
  79. package/adapters/claude/popup/buddy-popup.sh +0 -92
  80. package/adapters/claude/popup/buddy-render.sh +0 -540
  81. package/adapters/claude/popup/popup-manager.sh +0 -355
  82. package/adapters/claude/rendering/art.ts +0 -264
  83. package/adapters/claude/server/index.ts +0 -752
  84. package/adapters/claude/server/instructions.ts +0 -24
  85. package/adapters/claude/server/resources.ts +0 -38
  86. package/adapters/claude/statusline/buddy-status.sh +0 -389
  87. package/adapters/claude/storage/achievements.ts +0 -253
  88. package/adapters/claude/storage/identity.ts +0 -13
  89. package/adapters/claude/storage/paths.ts +0 -24
  90. package/adapters/claude/storage/settings.ts +0 -41
  91. package/adapters/pi/README.md +0 -83
  92. package/adapters/pi/tools.ts +0 -6
@@ -0,0 +1,551 @@
1
+ import type {
2
+ BashToolResultEvent,
3
+ ExtensionAPI,
4
+ InputEvent,
5
+ InputEventResult,
6
+ SessionStartEvent,
7
+ ToolResultEvent,
8
+ TurnEndEvent,
9
+ } from "@oh-my-pi/pi-coding-agent";
10
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
11
+ import { complete, type AssistantMessage, type TextContent, type UserMessage } from "@oh-my-pi/pi-ai";
12
+ import { BuddyCommandService, type ReactionResult } from "../../core/command-service.ts";
13
+ import type { BuddyTurnCommentModelConfig, Companion } from "../../core/model.ts";
14
+ import type { Achievement } from "../../core/achievements.ts";
15
+ import { getNameReaction, getSuccessReaction, isNameMentioned } from "../../core/reactions.ts";
16
+ import { OmpBuddyStorage } from "./storage.ts";
17
+ import { buildBuddyReactionPrompt, buildBuddyReactionSystemPrompt, normalizeBuddyComment, stripBuddyComments } from "./prompt.ts";
18
+ import { OmpBuddyUI } from "./ui.ts";
19
+ import type { OmpBuddyContext } from "./context.ts";
20
+
21
+ export type TurnCommentCompleter = (
22
+ model: Parameters<typeof complete>[0],
23
+ context: Parameters<typeof complete>[1],
24
+ options: Parameters<typeof complete>[2],
25
+ ) => Promise<AssistantMessage>;
26
+
27
+ export interface OmpBuddyLog {
28
+ info(event: string, data?: Record<string, unknown>): void;
29
+ warn(event: string, data?: Record<string, unknown>): void;
30
+ error(event: string, data?: Record<string, unknown>): void;
31
+ debug(event: string, data?: Record<string, unknown>): void;
32
+ }
33
+
34
+ interface RegisterOmpBuddyEventsDeps {
35
+ service: BuddyCommandService;
36
+ storage: OmpBuddyStorage;
37
+ ui: OmpBuddyUI;
38
+ logger: OmpBuddyLog;
39
+ completeTurnComment?: TurnCommentCompleter;
40
+ }
41
+
42
+ export function registerOmpBuddyEvents(pi: ExtensionAPI, deps: RegisterOmpBuddyEventsDeps): void {
43
+ pi.on("session_start", async (_event: SessionStartEvent, ctx: OmpBuddyContext) => {
44
+ const result = deps.service.startSession();
45
+ deps.logger.info("session_start", {
46
+ companion: result.companion.name,
47
+ species: result.companion.bones.species,
48
+ created: result.created,
49
+ });
50
+ deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
51
+ if (result.created) {
52
+ ctx.ui.notify(`A new buddy hatched: ${result.companion.name}`, "info");
53
+ }
54
+ deps.ui.notifyAchievements(ctx, result.achievements);
55
+ });
56
+
57
+ pi.on("input", async (event: InputEvent, ctx: OmpBuddyContext): Promise<InputEventResult> => {
58
+ if (event.source === "extension" || deps.storage.isMuted()) {
59
+ deps.logger.debug("input_skipped", {
60
+ source: event.source,
61
+ muted: deps.storage.isMuted(),
62
+ });
63
+ return { handled: false };
64
+ }
65
+
66
+ const companion = deps.service.ensureCompanion().companion;
67
+ if (!isNameMentioned(event.text, companion.name)) {
68
+ return { handled: false };
69
+ }
70
+
71
+ deps.logger.info("name_mention_detected", {
72
+ companion: companion.name,
73
+ species: companion.bones.species,
74
+ textLength: event.text.length,
75
+ });
76
+
77
+ const reaction = getNameReaction(companion.bones.species);
78
+ const result = deps.service.recordComment(reaction, "turn");
79
+ deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
80
+ deps.ui.notifyAchievements(ctx, result.achievements);
81
+ return { handled: false };
82
+ });
83
+
84
+ pi.on("tool_result", async (event: ToolResultEvent, ctx: OmpBuddyContext) => {
85
+ if (deps.storage.isMuted()) {
86
+ deps.logger.debug("tool_result_skipped", { reason: "muted" });
87
+ return;
88
+ }
89
+
90
+ const text = extractOmpToolText(event);
91
+ const canEmit = shouldEmitPassiveReaction(deps.storage);
92
+ let result: ReactionResult | undefined;
93
+
94
+ const companion = deps.service.ensureCompanion().companion;
95
+ if (isOmpBashToolResult(event)) {
96
+ if (looksLikeTestFailure(text)) {
97
+ const count = extractFailureCount(text);
98
+ if (canEmit) {
99
+ result = deps.service.recordTestFailure(undefined, count);
100
+ } else {
101
+ const achievements = deps.service.trackTestFailure(undefined, count);
102
+ if (achievements.length > 0) { deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), achievements); }
103
+ }
104
+ } else if (event.isError || (event.details?.exitCode ?? 0) !== 0) {
105
+ const line = firstLineNumber(text);
106
+ if (canEmit) {
107
+ result = deps.service.recordToolError(undefined, line);
108
+ } else {
109
+ const achievements = deps.service.trackToolError(undefined, line);
110
+ if (achievements.length > 0) { deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), achievements); }
111
+ }
112
+ } else {
113
+ const diffLines = extractLargeDiffLines(text);
114
+ if (diffLines >= 80) {
115
+ if (canEmit) {
116
+ result = deps.service.recordLargeDiff(diffLines);
117
+ } else {
118
+ const achievements = deps.service.trackLargeDiff(diffLines);
119
+ if (achievements.length > 0) { deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), achievements); }
120
+ }
121
+ } else if (canEmit && looksLikeSuccess(text)) {
122
+ result = deps.service.recordComment(getSuccessReaction(companion.bones.species), "turn");
123
+ }
124
+ }
125
+ } else if (event.isError) {
126
+ const line = firstLineNumber(text);
127
+ if (canEmit) {
128
+ result = deps.service.recordToolError(undefined, line);
129
+ } else {
130
+ const achievements = deps.service.trackToolError(undefined, line);
131
+ if (achievements.length > 0) { deps.ui.refresh(ctx, companion, deps.storage.loadLatest(), achievements); }
132
+ }
133
+ }
134
+
135
+ if (!canEmit) {
136
+ deps.logger.debug("tool_result_skipped", { reason: "cooldown" });
137
+ return;
138
+ }
139
+
140
+ if (!result) {
141
+ const ignoredData: Record<string, unknown> = { isError: event.isError, textLength: text.length };
142
+ if (diagnosticPreviewsEnabled()) {
143
+ ignoredData.textPreview = text.slice(0, 200);
144
+ }
145
+ deps.logger.debug("tool_result_ignored", ignoredData);
146
+ return;
147
+ }
148
+ deps.logger.info("tool_result_reaction", {
149
+ reason: result.state.reason,
150
+ reactionLength: result.state.reaction.length,
151
+ textLength: text.length,
152
+ });
153
+ deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
154
+ deps.ui.notifyAchievements(ctx, result.achievements);
155
+ });
156
+
157
+ pi.on("turn_end", async (event: TurnEndEvent, ctx: OmpBuddyContext) => {
158
+ const progress = deps.service.recordTurnOnly();
159
+ if (deps.storage.isMuted()) {
160
+ deps.logger.debug("turn_end_skipped", { reason: "muted" });
161
+ deps.ui.refresh(ctx, progress.companion, null, progress.achievements);
162
+ return;
163
+ }
164
+
165
+ const generated = await generateTurnComment(
166
+ ctx,
167
+ progress.companion,
168
+ event,
169
+ deps.logger,
170
+ deps.completeTurnComment ?? complete,
171
+ deps.storage.loadOmpConfig().turnCommentModel,
172
+ );
173
+ if (!generated.comment) {
174
+ const assistantLength = isAssistantMessage(event.message) ? getAssistantText(event.message).length : 0;
175
+ deps.logger.warn("turn_end_comment_missing", { assistantLength });
176
+ deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
177
+ return;
178
+ }
179
+
180
+ deps.logger.info("turn_end_reaction", {
181
+ source: generated.source,
182
+ reactionLength: generated.comment.length,
183
+ });
184
+
185
+ const reaction = deps.service.recordComment(generated.comment, "turn");
186
+ const achievements = mergeAchievements(progress.achievements, reaction.achievements);
187
+ deps.ui.refresh(ctx, reaction.companion, reaction.state, achievements);
188
+ deps.ui.notifyAchievements(ctx, achievements);
189
+ });
190
+ pi.on("session_shutdown", () => {
191
+ deps.ui.cancelTimers();
192
+ });
193
+
194
+ }
195
+
196
+ function diagnosticPreviewsEnabled(): boolean {
197
+ const value = process.env.BUDDY_DIAGNOSTIC_PREVIEWS;
198
+ return value === "1" || value === "true";
199
+ }
200
+
201
+ function shouldEmitPassiveReaction(storage: OmpBuddyStorage): boolean {
202
+ const cooldownSeconds = storage.loadOmpConfig().commentCooldown;
203
+ if (cooldownSeconds <= 0) return true;
204
+ const latest = storage.loadLatest();
205
+ if (!latest?.timestamp) return true;
206
+ return Date.now() - latest.timestamp >= cooldownSeconds * 1000;
207
+ }
208
+
209
+ export function isOmpBashToolResult(
210
+ event: ToolResultEvent,
211
+ ): event is BashToolResultEvent {
212
+ return event.toolName === "bash";
213
+ }
214
+
215
+ export function extractOmpToolText(event: ToolResultEvent): string {
216
+ return event.content
217
+ .filter((item): item is Extract<(typeof event.content)[number], { type: "text" }> => item.type === "text")
218
+ .map((item) => item.text)
219
+ .join("\n");
220
+ }
221
+
222
+ const EXPLICIT_FAILURE_PATTERN = /\b(?:[1-9][0-9]*\s+)?(?:tests?|specs?)\s+(?:failed|failing|did\s+not\s+pass)\b/i;
223
+
224
+ function hasTestContext(line: string): boolean {
225
+ return /\b(?:bun\s+test|npm\s+test|yarn\s+test|pnpm\s+test|vitest|jest|mocha|ava|tap|pytest|cargo\s+test|go\s+test|dotnet\s+test|phpunit|rspec|test\s+suite|test\s+runner|tests?\s+(?:passed|failed|ok)|TAP\s*version|1\.\.\d+|#\s+(?:tests|pass|fail|todo|skip|ok))\b/i.test(line);
226
+ }
227
+
228
+ function isGenericFailureMarker(line: string): boolean {
229
+ return /^\s*(?:FAIL|not\s+ok|[✗✘×])\s*$/i.test(line) ||
230
+ /\b[✗✘×]\b/.test(line) ||
231
+ /(?:^|[\r\n])\s*FAIL(?:\s|$)/i.test(line) ||
232
+ /\bnot\s+ok\b/i.test(line);
233
+ }
234
+
235
+ export function looksLikeTestFailure(text: string): boolean {
236
+ if (EXPLICIT_FAILURE_PATTERN.test(text)) return true;
237
+
238
+ const lines = text.split(/\r?\n/);
239
+ const contextWindow: string[] = [];
240
+ for (const rawLine of lines) {
241
+ const line = rawLine.trim();
242
+ contextWindow.push(line);
243
+ if (contextWindow.length > 3) contextWindow.shift();
244
+ if (isGenericFailureMarker(line) && contextWindow.some((contextLine) => hasTestContext(contextLine))) {
245
+ return true;
246
+ }
247
+ }
248
+ return false;
249
+ }
250
+
251
+ function looksLikeSuccess(text: string): boolean {
252
+ return /\b(all )?[0-9]+ tests? (passed|ok)\b|✓|✔|PASS(ED)?|\bDone\b|\bSuccess\b|exit code 0|Build succeeded/i.test(text);
253
+ }
254
+
255
+ function extractFailureCount(text: string): number | undefined {
256
+ const match = text.match(/(\d+)\s+(tests? )?(failed|failing)/i);
257
+ return match ? Number(match[1]) : undefined;
258
+ }
259
+
260
+ function extractLargeDiffLines(text: string): number {
261
+ const statMatch = text.match(/(\d+)\s+insertions?\(\+\).*?(\d+)\s+deletions?\(-\)/is);
262
+ if (statMatch) {
263
+ return Number(statMatch[1]) + Number(statMatch[2]);
264
+ }
265
+
266
+ const patchLines = text
267
+ .split(/\r?\n/)
268
+ .filter((line) => line.startsWith("+") || line.startsWith("-"))
269
+ .length;
270
+ return patchLines;
271
+ }
272
+
273
+ function firstLineNumber(text: string): number | undefined {
274
+ const match = text.match(/line\s+(\d+)/i);
275
+ return match ? Number(match[1]) : undefined;
276
+ }
277
+
278
+ function mergeAchievements(first: Achievement[], second: Achievement[]): Achievement[] {
279
+ const merged = new Map<string, Achievement>();
280
+ for (const achievement of [...first, ...second]) {
281
+ merged.set(achievement.id, achievement);
282
+ }
283
+ return [...merged.values()];
284
+ }
285
+
286
+ function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
287
+ return message.role === "assistant" && Array.isArray(message.content);
288
+ }
289
+
290
+ function getAssistantText(message: AssistantMessage): string {
291
+ return message.content
292
+ .filter((block): block is TextContent => block.type === "text")
293
+ .map((block) => stripBuddyComments(block.text))
294
+ .join("\n");
295
+ }
296
+
297
+ export async function generateTurnComment(
298
+ ctx: OmpBuddyContext,
299
+ companion: Companion,
300
+ event: TurnEndEvent,
301
+ logger: OmpBuddyLog,
302
+ completeTurnComment: TurnCommentCompleter = complete,
303
+ modelOverride?: BuddyTurnCommentModelConfig,
304
+ ): Promise<{ comment: string | null; source: "llm" | "fallback" | "none" }> {
305
+ const assistantText = isAssistantMessage(event.message) ? getAssistantText(event.message) : "";
306
+ if (!assistantText.trim()) {
307
+ logger.warn("turn_comment_skipped", { reason: "empty_assistant_text" });
308
+ return { comment: null, source: "none" };
309
+ }
310
+
311
+ 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,
320
+ 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
+ });
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);
340
+ let apiKey: string | undefined;
341
+ try {
342
+ apiKey = await ctx.modelRegistry.getApiKey(turnCommentModel);
343
+ } catch (error) {
344
+ logger.warn("turn_comment_auth_unavailable", {
345
+ message: error instanceof Error ? error.message : String(error),
346
+ });
347
+ }
348
+ const headers = ctx.modelRegistry.getProviderHeaders(turnCommentModel.provider);
349
+ logger.debug("turn_comment_auth", {
350
+ hasApiKey: Boolean(apiKey),
351
+ headerKeys: Object.keys(headers ?? {}),
352
+ });
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 {
397
+ logger.warn("turn_comment_auth_unavailable", {
398
+ message: "missing api key",
399
+ });
400
+ }
401
+ } else {
402
+ logger.warn("turn_comment_llm_skipped", { reason: "no_model" });
403
+ }
404
+
405
+ const fallback = deriveTurnComment(companion, event.message);
406
+ logger.warn("turn_comment_fallback", {
407
+ fallbackLength: fallback?.length ?? 0,
408
+ assistantLength: assistantText.length,
409
+ });
410
+ return { comment: fallback, source: fallback ? "fallback" : "none" };
411
+ }
412
+
413
+ export function resolveTurnCommentModel(
414
+ ctx: OmpBuddyContext,
415
+ logger?: OmpBuddyLog,
416
+ override?: BuddyTurnCommentModelConfig,
417
+ ): NonNullable<OmpBuddyContext["model"]> | null {
418
+ const activeOverride = override;
419
+ if (activeOverride?.provider && activeOverride.model) {
420
+ const model = ctx.modelRegistry.find(activeOverride.provider, activeOverride.model);
421
+ if (model) {
422
+ logger?.debug("turn_comment_model_selected", {
423
+ source: "config",
424
+ provider: model.provider,
425
+ model: model.id,
426
+ });
427
+ return model;
428
+ }
429
+ logger?.warn("turn_comment_model_missing", {
430
+ source: "config",
431
+ provider: activeOverride.provider,
432
+ model: activeOverride.model,
433
+ });
434
+ }
435
+
436
+ if (ctx.model) {
437
+ logger?.debug("turn_comment_model_selected", {
438
+ source: "session",
439
+ provider: ctx.model.provider,
440
+ model: ctx.model.id,
441
+ });
442
+ return ctx.model;
443
+ }
444
+
445
+ return null;
446
+ }
447
+
448
+ function getToolResultsText(event: TurnEndEvent): string {
449
+ return event.toolResults
450
+ .map((result) => result.content
451
+ .filter((block): block is TextContent => block.type === "text")
452
+ .map((block) => block.text)
453
+ .join("\n"))
454
+ .filter(Boolean)
455
+ .join("\n\n")
456
+ .slice(0, 4000);
457
+ }
458
+
459
+ function getUserPromptText(ctx: OmpBuddyContext): string {
460
+ const branch = ctx.sessionManager.getBranch();
461
+ for (let i = branch.length - 1; i >= 0; i--) {
462
+ const entry = branch[i];
463
+ if (entry.type !== "message") continue;
464
+ const message = entry.message;
465
+ if (message.role !== "user" || !Array.isArray(message.content)) continue;
466
+ return message.content
467
+ .filter((block): block is TextContent => block.type === "text")
468
+ .map((block) => block.text)
469
+ .join("\n")
470
+ .slice(0, 4000);
471
+ }
472
+ return "";
473
+ }
474
+
475
+ export function deriveTurnComment(companion: Companion, message: AgentMessage): string | null {
476
+ if (!isAssistantMessage(message)) return null;
477
+
478
+ const text = sanitizeAssistantText(getAssistantText(message));
479
+ if (!text) return null;
480
+
481
+ const file = firstMatch(text, /`([^`]+\.[a-z0-9]+)`/i)
482
+ ?? firstMatch(text, /\b([A-Za-z0-9_./-]+\.(?:ts|tsx|js|jsx|json|md|sh|py|rs|go|java|rb|css|html|ya?ml))\b/i);
483
+
484
+ if (file) {
485
+ return fitComment(`*takes note* ${file} got the attention this turn.`, 150);
486
+ }
487
+
488
+ if (/\b(regex|unicode)\b/i.test(text)) {
489
+ return fitComment("*head tilts* that regex still wants a second look.", 150);
490
+ }
491
+
492
+ if (/\b(test|tests|assert|spec)\b/i.test(text)) {
493
+ return fitComment("*nods slowly* good. keep the tests honest.", 150);
494
+ }
495
+
496
+ if (/\b(error|bug|fix|failure|failing|exception)\b/i.test(text)) {
497
+ return fitComment("*watches closely* one fix always tries to drag a second one behind it.", 150);
498
+ }
499
+
500
+ const topic = firstMeaningfulSentence(text);
501
+ if (!topic) return null;
502
+
503
+ const speciesLead = companion.bones.species === "owl"
504
+ ? "*blinks slowly*"
505
+ : companion.bones.species === "snail"
506
+ ? "*slow nod*"
507
+ : "*takes note*";
508
+
509
+ return fitComment(`${speciesLead} ${topic}`, 150);
510
+ }
511
+
512
+ function sanitizeAssistantText(text: string): string {
513
+ return text
514
+ .replace(/<!--([\s\S]*?)-->/g, " ")
515
+ .replace(/```[\s\S]*?```/g, " ")
516
+ .replace(/`([^`]+)`/g, "$1")
517
+ .replace(/^#{1,6}\s+/gm, "")
518
+ .replace(/^\s*[-*+]\s+/gm, "")
519
+ .replace(/\[(.*?)\]\((.*?)\)/g, "$1")
520
+ .replace(/\s+/g, " ")
521
+ .trim();
522
+ }
523
+
524
+ function firstMatch(text: string, pattern: RegExp): string | null {
525
+ const match = text.match(pattern);
526
+ return match?.[1]?.trim() || null;
527
+ }
528
+
529
+ function firstMeaningfulSentence(text: string): string | null {
530
+ const sentences = text
531
+ .split(/(?<=[.!?])\s+/)
532
+ .map((sentence) => sentence.trim())
533
+ .filter(Boolean);
534
+
535
+ for (const sentence of sentences) {
536
+ const cleaned = sentence
537
+ .replace(/^here'?s what I (?:did|changed)[:\-]?\s*/i, "")
538
+ .replace(/^I\s+/i, "")
539
+ .replace(/^we\s+/i, "")
540
+ .trim();
541
+ if (cleaned.length < 18) continue;
542
+ return cleaned;
543
+ }
544
+
545
+ return text.length >= 18 ? text : null;
546
+ }
547
+
548
+ function fitComment(text: string, maxLength: number): string {
549
+ if (text.length <= maxLength) return text;
550
+ return `${text.slice(0, maxLength - 1).trimEnd()}…`;
551
+ }
@@ -0,0 +1,10 @@
1
+ import type { IdentityProvider } from "../../core/ports.ts";
2
+ import { OmpBuddyStorage } from "./storage.ts";
3
+
4
+ export class OmpIdentityProvider implements IdentityProvider {
5
+ constructor(private readonly storage: OmpBuddyStorage) {}
6
+
7
+ getStableUserId(): string {
8
+ return this.storage.ensureStableIdentity();
9
+ }
10
+ }
@@ -0,0 +1,39 @@
1
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
+ import { BuddyCommandService } from "../../core/command-service.ts";
3
+ import { registerBuddyCommands } from "./commands.ts";
4
+ import { registerOmpBuddyEvents, type TurnCommentCompleter } from "./events.ts";
5
+ import { OmpIdentityProvider } from "./identity.ts";
6
+ import { OmpBuddyLogger } from "./logger.ts";
7
+ import { OmpBuddyStorage } from "./storage.ts";
8
+ import { OmpBuddyUI } from "./ui.ts";
9
+
10
+ export interface OmpBuddyExtensionOptions {
11
+ stateDir?: string;
12
+ completeTurnComment?: TurnCommentCompleter;
13
+ }
14
+
15
+ export default function registerOmpBuddyExtension(
16
+ omp: ExtensionAPI,
17
+ options: OmpBuddyExtensionOptions = {},
18
+ ): void {
19
+ const storage = new OmpBuddyStorage(options.stateDir);
20
+ const identity = new OmpIdentityProvider(storage);
21
+ const logger = new OmpBuddyLogger(storage);
22
+ const service = new BuddyCommandService({
23
+ identity,
24
+ buddies: storage,
25
+ reactions: storage,
26
+ config: storage,
27
+ events: storage,
28
+ });
29
+ const ui = new OmpBuddyUI(storage);
30
+
31
+ registerBuddyCommands(omp, { service, storage, ui });
32
+ registerOmpBuddyEvents(omp, {
33
+ service,
34
+ storage,
35
+ ui,
36
+ logger,
37
+ completeTurnComment: options.completeTurnComment,
38
+ });
39
+ }
@@ -0,0 +1,52 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import pino, { type Logger } from "pino";
4
+ import { OmpBuddyStorage } from "./storage.ts";
5
+
6
+ function resolveLogLevel(): pino.LevelWithSilent {
7
+ const level = (process.env.OMP_BUDDY_LOG_LEVEL
8
+ ?? process.env.CODING_BUDDY_LOG_LEVEL
9
+ ?? "info").toLowerCase();
10
+
11
+ if (["fatal", "error", "warn", "info", "debug", "trace", "silent"].includes(level)) {
12
+ return level as pino.LevelWithSilent;
13
+ }
14
+
15
+ return "info";
16
+ }
17
+
18
+ export class OmpBuddyLogger {
19
+ readonly filePath: string;
20
+ readonly logger: Logger;
21
+
22
+ constructor(storage: OmpBuddyStorage) {
23
+ const logDir = join(storage.stateDir, "logs");
24
+ mkdirSync(logDir, { recursive: true });
25
+ this.filePath = join(logDir, "buddy.log");
26
+ this.logger = pino(
27
+ {
28
+ name: "omp-buddy",
29
+ level: resolveLogLevel(),
30
+ base: undefined,
31
+ timestamp: pino.stdTimeFunctions.isoTime,
32
+ },
33
+ pino.destination({ dest: this.filePath, append: true, mkdir: true, sync: true }),
34
+ );
35
+ }
36
+
37
+ info(event: string, data: Record<string, unknown> = {}): void {
38
+ this.logger.info({ event, ...data });
39
+ }
40
+
41
+ warn(event: string, data: Record<string, unknown> = {}): void {
42
+ this.logger.warn({ event, ...data });
43
+ }
44
+
45
+ error(event: string, data: Record<string, unknown> = {}): void {
46
+ this.logger.error({ event, ...data });
47
+ }
48
+
49
+ debug(event: string, data: Record<string, unknown> = {}): void {
50
+ this.logger.debug({ event, ...data });
51
+ }
52
+ }