@ramarivera/coding-buddy 0.4.0-alpha.9 → 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.
- package/{adapters/claude/plugin → .claude-plugin}/marketplace.json +11 -6
- package/{adapters/claude/plugin → .claude-plugin}/plugin.json +7 -3
- package/LICENSE +1 -1
- package/README.md +88 -37
- package/adapters/omp/commands.ts +201 -0
- package/adapters/omp/context.ts +22 -0
- package/adapters/omp/events.ts +551 -0
- package/adapters/omp/identity.ts +10 -0
- package/adapters/omp/index.ts +39 -0
- package/adapters/omp/logger.ts +52 -0
- package/adapters/omp/prompt.ts +60 -0
- package/adapters/omp/renderers.ts +73 -0
- package/adapters/omp/storage.ts +25 -0
- package/adapters/omp/ui.ts +85 -0
- package/adapters/pi/commands.ts +64 -37
- package/adapters/pi/events.ts +147 -72
- package/adapters/pi/index.ts +9 -4
- package/adapters/pi/storage.ts +14 -285
- package/adapters/pi/ui.ts +56 -5
- package/adapters/shared/file-storage.ts +526 -0
- package/{adapters/claude/install → cli}/backup.ts +130 -33
- package/cli/buddy-shell.ts +1 -1
- package/{adapters/claude/install → cli}/disable.ts +19 -32
- package/cli/doctor.ts +231 -0
- package/{adapters/claude/install → cli}/hunt.ts +7 -9
- package/cli/index.ts +30 -26
- package/{adapters/claude/install → cli}/install.ts +65 -83
- package/{adapters/claude/install → cli}/pick.ts +111 -50
- package/{adapters/claude/install → cli}/settings.ts +2 -2
- package/{adapters/claude/install → cli}/show.ts +4 -4
- package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
- package/{adapters/claude/install → cli}/test-statusline.ts +21 -11
- package/cli/tui.tsx +106 -17
- package/{adapters/claude/install → cli}/uninstall.ts +40 -47
- package/cli/upgrade.ts +2 -2
- package/cli/validate-species.ts +209 -0
- package/cli/verify.ts +3 -3
- package/core/achievements.ts +12 -20
- package/core/art-data.ts +26 -0
- package/core/command-service.ts +99 -97
- package/core/engine.ts +83 -1
- package/core/ports.ts +10 -0
- package/core/reactions.ts +533 -95
- package/core/slot-slug.ts +22 -0
- package/{adapters/claude/hooks → hooks}/buddy-comment.sh +19 -1
- package/hooks/file-type-react.sh +401 -0
- package/hooks/hooks.json +52 -0
- package/hooks/mood-react.sh +208 -0
- package/{adapters/claude/hooks → hooks}/name-react.sh +1 -1
- package/hooks/react.sh +1368 -0
- package/hooks/suggest.sh +55 -0
- package/package.json +70 -31
- package/scripts/gen-emoji-widths.ts +34 -0
- package/scripts/paths.sh +17 -1
- package/scripts/postinstall.cjs +42 -0
- package/scripts/snapshot-statusline.sh +54 -0
- package/server/achievements.ts +1419 -15
- package/server/art.ts +450 -1
- package/server/award-xp.ts +48 -0
- package/server/check-suggestions.ts +102 -0
- package/server/consolidate.ts +33 -0
- package/{adapters/claude/server → server}/index.ts +556 -95
- package/server/mcp-launcher.sh +16 -6
- package/server/memory.ts +404 -0
- package/server/mood.ts +254 -0
- package/server/path.ts +70 -18
- package/server/shift-mood.ts +43 -0
- package/server/state.ts +540 -3
- package/server/suggestions.ts +335 -0
- package/server/theme.ts +53 -0
- package/server/xp.ts +435 -0
- package/{adapters/claude/skills → skills}/buddy/SKILL.md +36 -7
- package/statusline/buddy-status.sh +397 -0
- package/statusline/combined-status.sh +134 -0
- package/statusline/emoji-widths.data +4 -0
- package/adapters/claude/hooks/hooks.json +0 -35
- package/adapters/claude/hooks/react.sh +0 -207
- package/adapters/claude/install/doctor.ts +0 -172
- package/adapters/claude/rendering/art.ts +0 -264
- package/adapters/claude/server/instructions.ts +0 -24
- package/adapters/claude/server/path.ts +0 -12
- package/adapters/claude/server/resources.ts +0 -38
- package/adapters/claude/statusline/buddy-status.sh +0 -408
- package/adapters/claude/storage/achievements.ts +0 -253
- package/adapters/claude/storage/identity.ts +0 -13
- package/adapters/claude/storage/paths.ts +0 -33
- package/adapters/claude/storage/settings.ts +0 -91
- package/adapters/claude/storage/state.ts +0 -365
- package/adapters/pi/README.md +0 -83
- package/adapters/pi/tools.ts +0 -6
- package/server/engine.ts +0 -1
- package/server/reactions.ts +0 -1
|
@@ -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
|
+
}
|