@ramarivera/coding-buddy 0.4.0-alpha.7 → 0.4.0-alpha.9

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 (43) hide show
  1. package/README.md +18 -39
  2. package/adapters/claude/hooks/buddy-comment.sh +4 -1
  3. package/adapters/claude/hooks/name-react.sh +4 -1
  4. package/adapters/claude/hooks/react.sh +4 -1
  5. package/adapters/claude/install/backup.ts +36 -118
  6. package/adapters/claude/install/disable.ts +9 -14
  7. package/adapters/claude/install/doctor.ts +26 -87
  8. package/adapters/claude/install/install.ts +39 -66
  9. package/adapters/claude/install/test-statusline.ts +8 -18
  10. package/adapters/claude/install/uninstall.ts +18 -26
  11. package/adapters/claude/plugin/marketplace.json +4 -4
  12. package/adapters/claude/plugin/plugin.json +3 -5
  13. package/adapters/claude/server/index.ts +132 -5
  14. package/adapters/claude/server/path.ts +12 -0
  15. package/adapters/claude/skills/buddy/SKILL.md +16 -1
  16. package/adapters/claude/statusline/buddy-status.sh +22 -3
  17. package/adapters/claude/storage/paths.ts +9 -0
  18. package/adapters/claude/storage/settings.ts +53 -3
  19. package/adapters/claude/storage/state.ts +22 -4
  20. package/adapters/pi/README.md +19 -0
  21. package/adapters/pi/events.ts +176 -19
  22. package/adapters/pi/index.ts +3 -1
  23. package/adapters/pi/logger.ts +52 -0
  24. package/adapters/pi/prompt.ts +18 -0
  25. package/adapters/pi/storage.ts +1 -0
  26. package/cli/biomes.ts +309 -0
  27. package/cli/buddy-shell.ts +818 -0
  28. package/cli/index.ts +7 -0
  29. package/cli/tui.tsx +2244 -0
  30. package/cli/upgrade.ts +213 -0
  31. package/core/model.ts +6 -0
  32. package/package.json +78 -62
  33. package/scripts/paths.sh +40 -0
  34. package/server/achievements.ts +15 -0
  35. package/server/art.ts +1 -0
  36. package/server/engine.ts +1 -0
  37. package/server/mcp-launcher.sh +16 -0
  38. package/server/path.ts +30 -0
  39. package/server/reactions.ts +1 -0
  40. package/server/state.ts +3 -0
  41. package/adapters/claude/popup/buddy-popup.sh +0 -92
  42. package/adapters/claude/popup/buddy-render.sh +0 -540
  43. package/adapters/claude/popup/popup-manager.sh +0 -355
@@ -12,21 +12,28 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
12
12
  import { complete, type AssistantMessage, type TextContent, type UserMessage } from "@mariozechner/pi-ai";
13
13
  import { BuddyCommandService } from "../../core/command-service.ts";
14
14
  import type { Achievement } from "../../core/achievements.ts";
15
- import type { Companion } from "../../core/model.ts";
15
+ import type { BuddyTurnCommentModelConfig, Companion } from "../../core/model.ts";
16
16
  import { getNameReaction, getSuccessReaction } from "../../core/reactions.ts";
17
17
  import { PiBuddyStorage } from "./storage.ts";
18
- import { buildBuddyReactionPrompt, normalizeBuddyComment, stripBuddyComments } from "./prompt.ts";
18
+ import { buildBuddyReactionPrompt, buildBuddyReactionSystemPrompt, normalizeBuddyComment, stripBuddyComments } from "./prompt.ts";
19
19
  import { PiBuddyUI } from "./ui.ts";
20
+ import { PiBuddyLogger } from "./logger.ts";
20
21
 
21
22
  interface RegisterBuddyEventsDeps {
22
23
  service: BuddyCommandService;
23
24
  storage: PiBuddyStorage;
24
25
  ui: PiBuddyUI;
26
+ logger: PiBuddyLogger;
25
27
  }
26
28
 
27
29
  export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsDeps): void {
28
30
  pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
29
31
  const result = deps.service.ensureCompanion();
32
+ deps.logger.info("session_start", {
33
+ companion: result.companion.name,
34
+ species: result.companion.bones.species,
35
+ created: result.created,
36
+ });
30
37
  deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
31
38
  if (result.created) {
32
39
  ctx.ui.notify(`A new buddy hatched: ${result.companion.name}`, "info");
@@ -36,6 +43,10 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
36
43
 
37
44
  pi.on("input", async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult> => {
38
45
  if (event.source === "extension" || deps.storage.isMuted()) {
46
+ deps.logger.debug("input_skipped", {
47
+ source: event.source,
48
+ muted: deps.storage.isMuted(),
49
+ });
39
50
  return { action: "continue" };
40
51
  }
41
52
 
@@ -45,6 +56,12 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
45
56
  return { action: "continue" };
46
57
  }
47
58
 
59
+ deps.logger.info("name_mention_detected", {
60
+ companion: companion.name,
61
+ species: companion.bones.species,
62
+ textPreview: event.text.slice(0, 160),
63
+ });
64
+
48
65
  const reaction = getNameReaction(companion.bones.species);
49
66
  const result = deps.service.recordComment(reaction, "turn");
50
67
  deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
@@ -53,7 +70,14 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
53
70
  });
54
71
 
55
72
  pi.on("tool_result", async (event: ToolResultEvent, ctx: ExtensionContext) => {
56
- if (deps.storage.isMuted() || !shouldEmitPassiveReaction(deps.storage)) return;
73
+ if (deps.storage.isMuted()) {
74
+ deps.logger.debug("tool_result_skipped", { reason: "muted" });
75
+ return;
76
+ }
77
+ if (!shouldEmitPassiveReaction(deps.storage)) {
78
+ deps.logger.debug("tool_result_skipped", { reason: "cooldown" });
79
+ return;
80
+ }
57
81
 
58
82
  const text = extractToolText(event);
59
83
  let result:
@@ -77,7 +101,18 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
77
101
  }
78
102
  }
79
103
 
80
- if (!result) return;
104
+ if (!result) {
105
+ deps.logger.debug("tool_result_ignored", {
106
+ isError: event.isError,
107
+ textPreview: text.slice(0, 200),
108
+ });
109
+ return;
110
+ }
111
+ deps.logger.info("tool_result_reaction", {
112
+ reason: result.state.reason,
113
+ reaction: result.state.reaction,
114
+ textPreview: text.slice(0, 200),
115
+ });
81
116
  deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
82
117
  deps.ui.notifyAchievements(ctx, result.achievements);
83
118
  });
@@ -85,17 +120,26 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
85
120
  pi.on("turn_end", async (event: TurnEndEvent, ctx: ExtensionContext) => {
86
121
  const progress = deps.service.recordTurnOnly();
87
122
  if (deps.storage.isMuted()) {
123
+ deps.logger.debug("turn_end_skipped", { reason: "muted" });
88
124
  deps.ui.refresh(ctx, progress.companion, null, progress.achievements);
89
125
  return;
90
126
  }
91
127
 
92
- const comment = await generateTurnComment(ctx, progress.companion, event);
93
- if (!comment) {
128
+ const generated = await generateTurnComment(ctx, progress.companion, event, deps.logger);
129
+ if (!generated.comment) {
130
+ deps.logger.warn("turn_end_comment_missing", {
131
+ assistantPreview: isAssistantMessage(event.message) ? getAssistantText(event.message).slice(0, 200) : "",
132
+ });
94
133
  deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
95
134
  return;
96
135
  }
97
136
 
98
- const reaction = deps.service.recordComment(comment, "turn");
137
+ deps.logger.info("turn_end_reaction", {
138
+ source: generated.source,
139
+ reaction: generated.comment,
140
+ });
141
+
142
+ const reaction = deps.service.recordComment(generated.comment, "turn");
99
143
  const achievements = mergeAchievements(progress.achievements, reaction.achievements);
100
144
  deps.ui.refresh(ctx, reaction.companion, reaction.state, achievements);
101
145
  deps.ui.notifyAchievements(ctx, achievements);
@@ -181,27 +225,61 @@ async function generateTurnComment(
181
225
  ctx: ExtensionContext,
182
226
  companion: Companion,
183
227
  event: TurnEndEvent,
184
- ): Promise<string | null> {
228
+ logger: PiBuddyLogger,
229
+ ): Promise<{ comment: string | null; source: "llm" | "fallback" | "none" }> {
185
230
  const assistantText = isAssistantMessage(event.message) ? getAssistantText(event.message) : "";
186
- if (!assistantText.trim()) return null;
231
+ if (!assistantText.trim()) {
232
+ logger.warn("turn_comment_skipped", { reason: "empty_assistant_text" });
233
+ return { comment: null, source: "none" };
234
+ }
187
235
 
188
- if (ctx.model) {
189
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
236
+ const turnCommentModel = resolveTurnCommentModel(ctx, logger);
237
+ if (turnCommentModel) {
238
+ const toolResultsText = getToolResultsText(event);
239
+ const userText = getUserPromptText(ctx);
240
+ const systemPrompt = buildBuddyReactionSystemPrompt(companion);
241
+ const promptText = buildBuddyReactionPrompt(companion, assistantText, toolResultsText, userText);
242
+ logger.info("turn_comment_llm_attempt", {
243
+ modelProvider: turnCommentModel.provider,
244
+ modelId: turnCommentModel.id,
245
+ assistantPreview: assistantText.slice(0, 200),
246
+ toolPreview: toolResultsText.slice(0, 200),
247
+ assistantLength: assistantText.length,
248
+ toolLength: toolResultsText.length,
249
+ promptLength: promptText.length,
250
+ systemPromptLength: systemPrompt.length,
251
+ toolResultCount: event.toolResults.length,
252
+ });
253
+ logger.debug("turn_comment_llm_prompt", {
254
+ systemPromptPreview: systemPrompt.slice(0, 800),
255
+ promptPreview: promptText.slice(0, 1200),
256
+ userText,
257
+ assistantText,
258
+ toolResultsText,
259
+ });
260
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(turnCommentModel);
261
+ logger.debug("turn_comment_auth", {
262
+ ok: auth.ok,
263
+ hasApiKey: auth.ok ? !!auth.apiKey : false,
264
+ headerKeys: auth.ok ? Object.keys(auth.headers ?? {}) : [],
265
+ });
190
266
  if (auth.ok && auth.apiKey) {
191
267
  const userMessage: UserMessage = {
192
268
  role: "user",
193
- content: [{ type: "text", text: buildBuddyReactionPrompt(companion, assistantText, getToolResultsText(event)) }],
269
+ content: [{ type: "text", text: promptText }],
194
270
  timestamp: Date.now(),
195
271
  };
196
272
 
197
273
  try {
198
274
  const response = await complete(
199
- ctx.model,
200
- { messages: [userMessage] },
275
+ turnCommentModel,
276
+ {
277
+ systemPrompt,
278
+ messages: [userMessage],
279
+ },
201
280
  {
202
281
  apiKey: auth.apiKey,
203
282
  headers: auth.headers,
204
- signal: ctx.signal,
205
283
  },
206
284
  );
207
285
 
@@ -211,15 +289,78 @@ async function generateTurnComment(
211
289
  .map((block) => block.text)
212
290
  .join("\n");
213
291
  const normalized = normalizeBuddyComment(text);
214
- if (normalized) return normalized;
292
+ logger.info("turn_comment_llm_result", {
293
+ stopReason: response.stopReason,
294
+ errorMessage: "errorMessage" in response ? (response as { errorMessage?: string }).errorMessage : undefined,
295
+ contentTypes: response.content.map((block) => block.type),
296
+ contentCount: response.content.length,
297
+ rawPreview: text.slice(0, 200),
298
+ rawLength: text.length,
299
+ normalized,
300
+ });
301
+ if (normalized) return { comment: normalized, source: "llm" };
302
+ logger.warn("turn_comment_llm_empty", {
303
+ rawPreview: text.slice(0, 200),
304
+ });
215
305
  }
216
- } catch {
217
- // Fall through to heuristic fallback.
306
+ } catch (error) {
307
+ logger.error("turn_comment_llm_error", {
308
+ message: error instanceof Error ? error.message : String(error),
309
+ stack: error instanceof Error ? error.stack : undefined,
310
+ });
218
311
  }
312
+ } else {
313
+ logger.warn("turn_comment_auth_unavailable", {
314
+ ok: auth.ok,
315
+ message: auth.ok ? "missing api key" : auth.error,
316
+ });
317
+ }
318
+ } else {
319
+ logger.warn("turn_comment_llm_skipped", { reason: "no_model" });
320
+ }
321
+
322
+ const fallback = deriveTurnComment(companion, event.message);
323
+ logger.warn("turn_comment_fallback", {
324
+ fallback,
325
+ assistantPreview: assistantText.slice(0, 200),
326
+ });
327
+ return { comment: fallback, source: fallback ? "fallback" : "none" };
328
+ }
329
+
330
+ export function resolveTurnCommentModel(
331
+ ctx: ExtensionContext,
332
+ logger?: PiBuddyLogger,
333
+ override?: BuddyTurnCommentModelConfig,
334
+ ): NonNullable<ExtensionContext["model"]> | null {
335
+ const configuredOverride = override ?? new PiBuddyStorage().loadPiConfig().turnCommentModel;
336
+ const activeOverride = configuredOverride;
337
+ if (activeOverride?.provider && activeOverride.model) {
338
+ const model = ctx.modelRegistry.find(activeOverride.provider, activeOverride.model);
339
+ if (model) {
340
+ logger?.debug("turn_comment_model_selected", {
341
+ source: "config",
342
+ provider: model.provider,
343
+ model: model.id,
344
+ });
345
+ return model;
219
346
  }
347
+ logger?.warn("turn_comment_model_missing", {
348
+ source: "config",
349
+ provider: activeOverride.provider,
350
+ model: activeOverride.model,
351
+ });
220
352
  }
221
353
 
222
- return deriveTurnComment(companion, event.message);
354
+ if (ctx.model) {
355
+ logger?.debug("turn_comment_model_selected", {
356
+ source: "session",
357
+ provider: ctx.model.provider,
358
+ model: ctx.model.id,
359
+ });
360
+ return ctx.model;
361
+ }
362
+
363
+ return null;
223
364
  }
224
365
 
225
366
  function getToolResultsText(event: TurnEndEvent): string {
@@ -233,6 +374,22 @@ function getToolResultsText(event: TurnEndEvent): string {
233
374
  .slice(0, 4000);
234
375
  }
235
376
 
377
+ function getUserPromptText(ctx: ExtensionContext): string {
378
+ const branch = ctx.sessionManager.getBranch();
379
+ for (let i = branch.length - 1; i >= 0; i--) {
380
+ const entry = branch[i];
381
+ if (entry.type !== "message") continue;
382
+ const message = entry.message;
383
+ if (message.role !== "user" || !Array.isArray(message.content)) continue;
384
+ return message.content
385
+ .filter((block): block is TextContent => block.type === "text")
386
+ .map((block) => block.text)
387
+ .join("\n")
388
+ .slice(0, 4000);
389
+ }
390
+ return "";
391
+ }
392
+
236
393
  export function deriveTurnComment(companion: Companion, message: AgentMessage): string | null {
237
394
  if (!isAssistantMessage(message)) return null;
238
395
 
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { BuddyCommandService } from "../../core/command-service.ts";
3
3
  import { PiIdentityProvider } from "./identity.ts";
4
+ import { PiBuddyLogger } from "./logger.ts";
4
5
  import { registerBuddyCommands } from "./commands.ts";
5
6
  import { registerBuddyEvents } from "./events.ts";
6
7
  import { PiBuddyStorage } from "./storage.ts";
@@ -10,6 +11,7 @@ import { PiBuddyUI } from "./ui.ts";
10
11
  export default function registerPiBuddyExtension(pi: ExtensionAPI): void {
11
12
  const storage = new PiBuddyStorage();
12
13
  const identity = new PiIdentityProvider(storage);
14
+ const logger = new PiBuddyLogger(storage);
13
15
  const service = new BuddyCommandService({
14
16
  identity,
15
17
  buddies: storage,
@@ -20,6 +22,6 @@ export default function registerPiBuddyExtension(pi: ExtensionAPI): void {
20
22
  const ui = new PiBuddyUI(storage);
21
23
 
22
24
  registerBuddyCommands(pi, { service, storage, ui });
23
- registerBuddyEvents(pi, { service, storage, ui });
25
+ registerBuddyEvents(pi, { service, storage, ui, logger });
24
26
  registerBuddyTools(pi);
25
27
  }
@@ -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 { PiBuddyStorage } from "./storage.ts";
5
+
6
+ function resolveLogLevel(): pino.LevelWithSilent {
7
+ const level = (process.env.PI_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 PiBuddyLogger {
19
+ readonly filePath: string;
20
+ readonly logger: Logger;
21
+
22
+ constructor(storage: PiBuddyStorage) {
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: "pi-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
+ }
@@ -1,9 +1,24 @@
1
1
  import type { Companion } from "../../core/model.ts";
2
2
 
3
+ export function buildBuddyReactionSystemPrompt(companion: Companion): string {
4
+ return [
5
+ `A small ${companion.bones.rarity} ${companion.bones.species} named ${companion.name} watches from the status line. You are not ${companion.name} — it is a separate creature.`,
6
+ `Personality: ${companion.personality}`,
7
+ `Peak stat: ${companion.bones.peak} (${companion.bones.stats[companion.bones.peak]}). Dump stat: ${companion.bones.dump} (${companion.bones.stats[companion.bones.dump]}).`,
8
+ "",
9
+ `Write as ${companion.name} (a ${companion.bones.species}), not as the assistant.`,
10
+ "Reference something SPECIFIC from this turn — a pitfall, compliment, warning, pattern, file, test, or edge case.",
11
+ "Return exactly one short sentence, max 150 chars.",
12
+ "Use *asterisks* for physical actions when useful.",
13
+ "Do not explain yourself. Do not use markdown fences. Output only the buddy reaction line.",
14
+ ].join("\n");
15
+ }
16
+
3
17
  export function buildBuddyReactionPrompt(
4
18
  companion: Companion,
5
19
  assistantText: string,
6
20
  toolText: string,
21
+ userText: string,
7
22
  ): string {
8
23
  return [
9
24
  `You are generating a single end-of-turn buddy reaction for ${companion.name}, a ${companion.bones.rarity} ${companion.bones.species}.`,
@@ -18,6 +33,9 @@ export function buildBuddyReactionPrompt(
18
33
  "- Use *asterisks* for physical actions when useful.",
19
34
  "- Output only the reaction line, with no quotes, labels, markdown fences, or explanation.",
20
35
  "",
36
+ "User prompt:",
37
+ userText || "(unknown)",
38
+ "",
21
39
  "Assistant response:",
22
40
  assistantText || "(empty)",
23
41
  "",
@@ -60,6 +60,7 @@ const DEFAULT_CONFIG: PiBuddyConfig = {
60
60
  bubblePosition: "top",
61
61
  showRarity: true,
62
62
  statusLineEnabled: true,
63
+ turnCommentModel: undefined,
63
64
  muted: false,
64
65
  };
65
66