@ramarivera/coding-buddy 0.4.0-alpha.6 → 0.4.0-alpha.8

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Permanent coding companion for Claude Code",
8
- "version": "0.4.0-alpha.6"
8
+ "version": "0.4.0-alpha.8"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-buddy",
13
13
  "source": "./",
14
14
  "description": "Permanent coding companion for Claude Code \u2014 survives any update. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
15
- "version": "0.4.0-alpha.6",
15
+ "version": "0.4.0-alpha.8",
16
16
  "author": {
17
17
  "name": "1270011"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-buddy",
3
- "version": "0.4.0-alpha.6"
3
+ "version": "0.4.0-alpha.8"
4
4
  "description": "Permanent coding companion for Claude Code \u2014 survives any update. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
5
5
  "author": {
6
6
  "name": "1270011"
@@ -89,7 +89,7 @@ const buddyService = new BuddyCommandService({
89
89
  const server = new McpServer(
90
90
  {
91
91
  name: "claude-buddy",
92
- version: "0.4.0-alpha.6",
92
+ version: "0.4.0-alpha.7",
93
93
  },
94
94
  {
95
95
  instructions: getInstructions(buddyService.loadActiveCompanion()),
@@ -62,3 +62,22 @@ Passive behavior:
62
62
  Persistence lives under:
63
63
 
64
64
  - `~/.pi/agent/buddy/`
65
+
66
+ ## Config
67
+
68
+ Buddy config is stored in:
69
+
70
+ - `~/.pi/agent/buddy/config.json`
71
+
72
+ You can optionally force the end-of-turn buddy comment generator to use a different model than the main pi session:
73
+
74
+ ```json
75
+ {
76
+ "turnCommentModel": {
77
+ "provider": "google",
78
+ "model": "gemini-2.5-flash"
79
+ }
80
+ }
81
+ ```
82
+
83
+ If `turnCommentModel` is unset, or the configured model cannot be found, buddy falls back to the active session model.
@@ -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,22 +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
- if (!shouldEmitPassiveReaction(deps.storage)) {
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
+ });
93
133
  deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
94
134
  return;
95
135
  }
96
136
 
97
- const comment = await generateTurnComment(ctx, progress.companion, event);
98
- if (!comment) {
99
- deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
100
- return;
101
- }
137
+ deps.logger.info("turn_end_reaction", {
138
+ source: generated.source,
139
+ reaction: generated.comment,
140
+ });
102
141
 
103
- const reaction = deps.service.recordComment(comment, "turn");
142
+ const reaction = deps.service.recordComment(generated.comment, "turn");
104
143
  const achievements = mergeAchievements(progress.achievements, reaction.achievements);
105
144
  deps.ui.refresh(ctx, reaction.companion, reaction.state, achievements);
106
145
  deps.ui.notifyAchievements(ctx, achievements);
@@ -186,27 +225,61 @@ async function generateTurnComment(
186
225
  ctx: ExtensionContext,
187
226
  companion: Companion,
188
227
  event: TurnEndEvent,
189
- ): Promise<string | null> {
228
+ logger: PiBuddyLogger,
229
+ ): Promise<{ comment: string | null; source: "llm" | "fallback" | "none" }> {
190
230
  const assistantText = isAssistantMessage(event.message) ? getAssistantText(event.message) : "";
191
- 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
+ }
192
235
 
193
- if (ctx.model) {
194
- 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
+ });
195
266
  if (auth.ok && auth.apiKey) {
196
267
  const userMessage: UserMessage = {
197
268
  role: "user",
198
- content: [{ type: "text", text: buildBuddyReactionPrompt(companion, assistantText, getToolResultsText(event)) }],
269
+ content: [{ type: "text", text: promptText }],
199
270
  timestamp: Date.now(),
200
271
  };
201
272
 
202
273
  try {
203
274
  const response = await complete(
204
- ctx.model,
205
- { messages: [userMessage] },
275
+ turnCommentModel,
276
+ {
277
+ systemPrompt,
278
+ messages: [userMessage],
279
+ },
206
280
  {
207
281
  apiKey: auth.apiKey,
208
282
  headers: auth.headers,
209
- signal: ctx.signal,
210
283
  },
211
284
  );
212
285
 
@@ -216,15 +289,78 @@ async function generateTurnComment(
216
289
  .map((block) => block.text)
217
290
  .join("\n");
218
291
  const normalized = normalizeBuddyComment(text);
219
- 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
+ });
220
305
  }
221
- } catch {
222
- // 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
+ });
223
311
  }
312
+ } else {
313
+ logger.warn("turn_comment_auth_unavailable", {
314
+ ok: auth.ok,
315
+ message: auth.ok ? "missing api key" : auth.error,
316
+ });
224
317
  }
318
+ } else {
319
+ logger.warn("turn_comment_llm_skipped", { reason: "no_model" });
225
320
  }
226
321
 
227
- return deriveTurnComment(companion, event.message);
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;
346
+ }
347
+ logger?.warn("turn_comment_model_missing", {
348
+ source: "config",
349
+ provider: activeOverride.provider,
350
+ model: activeOverride.model,
351
+ });
352
+ }
353
+
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;
228
364
  }
229
365
 
230
366
  function getToolResultsText(event: TurnEndEvent): string {
@@ -238,6 +374,22 @@ function getToolResultsText(event: TurnEndEvent): string {
238
374
  .slice(0, 4000);
239
375
  }
240
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
+
241
393
  export function deriveTurnComment(companion: Companion, message: AgentMessage): string | null {
242
394
  if (!isAssistantMessage(message)) return null;
243
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
 
package/core/model.ts CHANGED
@@ -26,6 +26,11 @@ export interface ReactionState {
26
26
  reason: string;
27
27
  }
28
28
 
29
+ export interface BuddyTurnCommentModelConfig {
30
+ provider: string;
31
+ model: string;
32
+ }
33
+
29
34
  export interface BuddyConfig {
30
35
  commentCooldown: number;
31
36
  reactionTTL: number;
@@ -33,6 +38,7 @@ export interface BuddyConfig {
33
38
  bubblePosition: "top" | "left";
34
39
  showRarity: boolean;
35
40
  statusLineEnabled: boolean;
41
+ turnCommentModel?: BuddyTurnCommentModelConfig;
36
42
  }
37
43
 
38
44
  export interface GlobalCounters {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.4.0-alpha.6",
3
+ "version": "0.4.0-alpha.8",
4
4
  "description": "Persistent coding companion for Claude Code and pi",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,7 +55,8 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@mariozechner/pi-coding-agent": "0.66.1",
58
- "@modelcontextprotocol/sdk": "^1.12.1"
58
+ "@modelcontextprotocol/sdk": "^1.12.1",
59
+ "pino": "^10.3.1"
59
60
  },
60
61
  "devDependencies": {
61
62
  "bun-types": "^1.3.11",