@ramarivera/coding-buddy 0.4.0-alpha.4 → 0.4.0-alpha.5

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.3.0"
8
+ "version": "0.4.0-alpha.5"
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.3.0",
15
+ "version": "0.4.0-alpha.5",
16
16
  "author": {
17
17
  "name": "1270011"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-buddy",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-alpha.5",
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.3.0",
92
+ version: "0.4.0-alpha.5",
93
93
  },
94
94
  {
95
95
  instructions: getInstructions(buddyService.loadActiveCompanion()),
@@ -81,7 +81,7 @@ export function registerBuddyCommands(pi: ExtensionAPI, deps: RegisterBuddyComma
81
81
  case "on": {
82
82
  deps.service.incrementCommandsRun();
83
83
  deps.storage.setMuted(false);
84
- const result = deps.service.recordTurn();
84
+ const result = deps.service.recordComment("*stretches* I'm back!");
85
85
  deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
86
86
  ctx.ui.notify(`${result.companion.name} is back.`, "info");
87
87
  deps.ui.notifyAchievements(ctx, result.achievements);
@@ -8,7 +8,12 @@ import type {
8
8
  TurnEndEvent,
9
9
  } from "@mariozechner/pi-coding-agent";
10
10
  import { isBashToolResult } from "@mariozechner/pi-coding-agent";
11
+ import type { AgentMessage } from "@mariozechner/pi-agent-core";
12
+ import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
11
13
  import { BuddyCommandService } from "../../core/command-service.ts";
14
+ import type { Achievement } from "../../core/achievements.ts";
15
+ import type { Companion } from "../../core/model.ts";
16
+ import { getNameReaction, getSuccessReaction } from "../../core/reactions.ts";
12
17
  import { PiBuddyStorage } from "./storage.ts";
13
18
  import { PiBuddyUI } from "./ui.ts";
14
19
 
@@ -39,11 +44,8 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
39
44
  return { action: "continue" };
40
45
  }
41
46
 
42
- if (!shouldEmitPassiveReaction(deps.storage)) {
43
- return { action: "continue" };
44
- }
45
-
46
- const result = deps.service.recordNameMention();
47
+ const reaction = getNameReaction(companion.bones.species);
48
+ const result = deps.service.recordComment(reaction, "turn");
47
49
  deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
48
50
  deps.ui.notifyAchievements(ctx, result.achievements);
49
51
  return { action: "continue" };
@@ -57,8 +59,10 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
57
59
  | ReturnType<BuddyCommandService["recordToolError"]>
58
60
  | ReturnType<BuddyCommandService["recordTestFailure"]>
59
61
  | ReturnType<BuddyCommandService["recordLargeDiff"]>
62
+ | ReturnType<BuddyCommandService["recordComment"]>
60
63
  | undefined;
61
64
 
65
+ const companion = deps.service.ensureCompanion().companion;
62
66
  if (event.isError) {
63
67
  result = deps.service.recordToolError(undefined, firstLineNumber(text));
64
68
  } else if (looksLikeTestFailure(text)) {
@@ -67,6 +71,8 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
67
71
  const diffLines = extractLargeDiffLines(text);
68
72
  if (diffLines >= 80) {
69
73
  result = deps.service.recordLargeDiff(diffLines);
74
+ } else if (looksLikeSuccess(text)) {
75
+ result = deps.service.recordComment(getSuccessReaction(companion.bones.species), "turn");
70
76
  }
71
77
  }
72
78
 
@@ -75,25 +81,28 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
75
81
  deps.ui.notifyAchievements(ctx, result.achievements);
76
82
  });
77
83
 
78
- pi.on("turn_end", async (_event: TurnEndEvent, ctx: ExtensionContext) => {
79
- const result = deps.service.recordTurn();
84
+ pi.on("turn_end", async (event: TurnEndEvent, ctx: ExtensionContext) => {
85
+ const progress = deps.service.recordTurnOnly();
80
86
  if (deps.storage.isMuted()) {
81
- deps.ui.refresh(ctx, result.companion, null, result.achievements);
87
+ deps.ui.refresh(ctx, progress.companion, null, progress.achievements);
82
88
  return;
83
89
  }
84
90
 
85
91
  if (!shouldEmitPassiveReaction(deps.storage)) {
86
- deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
92
+ deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
87
93
  return;
88
94
  }
89
95
 
90
- if (Math.random() < 0.35) {
91
- deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
92
- deps.ui.notifyAchievements(ctx, result.achievements);
96
+ const comment = deriveTurnComment(progress.companion, event.message);
97
+ if (!comment) {
98
+ deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
93
99
  return;
94
100
  }
95
101
 
96
- deps.ui.refresh(ctx, result.companion, deps.storage.loadLatest(), result.achievements);
102
+ const reaction = deps.service.recordComment(comment, "turn");
103
+ const achievements = mergeAchievements(progress.achievements, reaction.achievements);
104
+ deps.ui.refresh(ctx, reaction.companion, reaction.state, achievements);
105
+ deps.ui.notifyAchievements(ctx, achievements);
97
106
  });
98
107
  }
99
108
 
@@ -122,6 +131,10 @@ function looksLikeTestFailure(text: string): boolean {
122
131
  return /(FAIL|failed|failing|test(s)? failed|not ok)/i.test(text);
123
132
  }
124
133
 
134
+ function looksLikeSuccess(text: string): boolean {
135
+ return /\b(all )?[0-9]+ tests? (passed|ok)\b|✓|✔|PASS(ED)?|\bDone\b|\bSuccess\b|exit code 0|Build succeeded/i.test(text);
136
+ }
137
+
125
138
  function extractFailureCount(text: string): number | undefined {
126
139
  const match = text.match(/(\d+)\s+(tests? )?(failed|failing)/i);
127
140
  return match ? Number(match[1]) : undefined;
@@ -148,3 +161,100 @@ function firstLineNumber(text: string): number | undefined {
148
161
  function escapeRegExp(value: string): string {
149
162
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
150
163
  }
164
+
165
+ function mergeAchievements(first: Achievement[], second: Achievement[]): Achievement[] {
166
+ const merged = new Map<string, Achievement>();
167
+ for (const achievement of [...first, ...second]) {
168
+ merged.set(achievement.id, achievement);
169
+ }
170
+ return [...merged.values()];
171
+ }
172
+
173
+ function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
174
+ return message.role === "assistant" && Array.isArray(message.content);
175
+ }
176
+
177
+ function getAssistantText(message: AssistantMessage): string {
178
+ return message.content
179
+ .filter((block): block is TextContent => block.type === "text")
180
+ .map((block) => block.text)
181
+ .join("\n");
182
+ }
183
+
184
+ export function deriveTurnComment(companion: Companion, message: AgentMessage): string | null {
185
+ if (!isAssistantMessage(message)) return null;
186
+
187
+ const text = sanitizeAssistantText(getAssistantText(message));
188
+ if (!text) return null;
189
+
190
+ const file = firstMatch(text, /`([^`]+\.[a-z0-9]+)`/i)
191
+ ?? 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);
192
+
193
+ if (file) {
194
+ return fitComment(`*takes note* ${file} got the attention this turn.`, 150);
195
+ }
196
+
197
+ if (/\b(regex|unicode)\b/i.test(text)) {
198
+ return fitComment("*head tilts* that regex still wants a second look.", 150);
199
+ }
200
+
201
+ if (/\b(test|tests|assert|spec)\b/i.test(text)) {
202
+ return fitComment("*nods slowly* good. keep the tests honest.", 150);
203
+ }
204
+
205
+ if (/\b(error|bug|fix|failure|failing|exception)\b/i.test(text)) {
206
+ return fitComment("*watches closely* one fix always tries to drag a second one behind it.", 150);
207
+ }
208
+
209
+ const topic = firstMeaningfulSentence(text);
210
+ if (!topic) return null;
211
+
212
+ const speciesLead = companion.bones.species === "owl"
213
+ ? "*blinks slowly*"
214
+ : companion.bones.species === "snail"
215
+ ? "*slow nod*"
216
+ : "*takes note*";
217
+
218
+ return fitComment(`${speciesLead} ${topic}`, 150);
219
+ }
220
+
221
+ function sanitizeAssistantText(text: string): string {
222
+ return text
223
+ .replace(/<!--([\s\S]*?)-->/g, " ")
224
+ .replace(/```[\s\S]*?```/g, " ")
225
+ .replace(/`([^`]+)`/g, "$1")
226
+ .replace(/^#{1,6}\s+/gm, "")
227
+ .replace(/^\s*[-*+]\s+/gm, "")
228
+ .replace(/\[(.*?)\]\((.*?)\)/g, "$1")
229
+ .replace(/\s+/g, " ")
230
+ .trim();
231
+ }
232
+
233
+ function firstMatch(text: string, pattern: RegExp): string | null {
234
+ const match = text.match(pattern);
235
+ return match?.[1]?.trim() || null;
236
+ }
237
+
238
+ function firstMeaningfulSentence(text: string): string | null {
239
+ const sentences = text
240
+ .split(/(?<=[.!?])\s+/)
241
+ .map((sentence) => sentence.trim())
242
+ .filter(Boolean);
243
+
244
+ for (const sentence of sentences) {
245
+ const cleaned = sentence
246
+ .replace(/^here'?s what I (?:did|changed)[:\-]?\s*/i, "")
247
+ .replace(/^I\s+/i, "")
248
+ .replace(/^we\s+/i, "")
249
+ .trim();
250
+ if (cleaned.length < 18) continue;
251
+ return cleaned;
252
+ }
253
+
254
+ return text.length >= 18 ? text : null;
255
+ }
256
+
257
+ function fitComment(text: string, maxLength: number): string {
258
+ if (text.length <= maxLength) return text;
259
+ return `${text.slice(0, maxLength - 1).trimEnd()}…`;
260
+ }
@@ -40,6 +40,12 @@ export interface ReactionResult {
40
40
  achievements: Achievement[];
41
41
  }
42
42
 
43
+ export interface ProgressResult {
44
+ companion: Companion;
45
+ slot: string;
46
+ achievements: Achievement[];
47
+ }
48
+
43
49
  export interface SaveBuddyResult {
44
50
  companion: Companion;
45
51
  slot: string;
@@ -209,6 +215,24 @@ export class BuddyCommandService {
209
215
  return this.saveReaction("turn", companion, slot, scope);
210
216
  }
211
217
 
218
+ recordTurnOnly(): ProgressResult {
219
+ const { companion, slot } = this.ensureCompanion();
220
+ this.deps.events.increment("turns", 1);
221
+ const achievements = this.unlockAchievements(slot);
222
+ return { companion, slot, achievements };
223
+ }
224
+
225
+ recordComment(
226
+ comment: string,
227
+ reason: "turn" | "error" | "test-fail" | "large-diff" = "turn",
228
+ scope?: string,
229
+ ): ReactionResult {
230
+ const trimmed = comment.trim();
231
+ if (!trimmed) throw new Error("Buddy comment cannot be empty.");
232
+ const { companion, slot } = this.ensureCompanion();
233
+ return this.saveCustomReaction(trimmed, reason, companion, slot, scope);
234
+ }
235
+
212
236
  incrementCommandsRun(): Achievement[] {
213
237
  this.deps.events.increment("commands_run", 1, this.getActiveSlotOrUndefined());
214
238
  return this.unlockAchievements(this.getActiveSlotOrUndefined());
@@ -292,6 +316,16 @@ export class BuddyCommandService {
292
316
  companion.bones.rarity,
293
317
  context,
294
318
  );
319
+ return this.saveCustomReaction(reaction, reason, companion, slot, scope);
320
+ }
321
+
322
+ private saveCustomReaction(
323
+ reaction: string,
324
+ reason: "pet" | "turn" | "error" | "test-fail" | "large-diff",
325
+ companion: Companion,
326
+ slot: string,
327
+ scope?: string,
328
+ ): ReactionResult {
295
329
  const state: ReactionState = {
296
330
  reaction,
297
331
  reason,
package/core/reactions.ts CHANGED
@@ -6,6 +6,51 @@ import type { Species, Rarity, StatName } from "./engine.ts";
6
6
 
7
7
  type ReactionReason = "hatch" | "pet" | "error" | "test-fail" | "large-diff" | "turn" | "idle";
8
8
 
9
+ const NAME_REACTIONS: Partial<Record<Species, string[]>> = {
10
+ dragon: ["*one eye opens slowly*", "...you called?", "*smoke curls from nostril* yes.", "*regards you from above*"],
11
+ owl: ["*swivels head 180°*", "*blinks once, deliberately*", "hm.", "*adjusts perch*"],
12
+ cat: ["*ear flicks*", "...what.", "*ignores you, but heard*", "*opens one eye*"],
13
+ duck: ["*quack*", "*looks up mid-waddle*", "*attentive duck noises*"],
14
+ ghost: ["*materialises*", "...boo?", "*phases closer*"],
15
+ robot: ["NAME DETECTED.", "*whirrs attentively*", "STANDING BY."],
16
+ capybara: ["*barely moves*", "*blinks slowly*", "...yes, friend."],
17
+ axolotl: ["*gill flutter*", "*smiles gently*", "oh! hello."],
18
+ blob: ["*jiggles*", "*oozes toward you*", "*wobbles excitedly*"],
19
+ turtle: ["*slowly extends neck*", "...you called?", "*ancient eyes open*", "*shell creaks thoughtfully*", "*blinks once, patiently*"],
20
+ goose: ["HONK.", "*necks aggressively*", "*wing flap*", "*honks in recognition*"],
21
+ octopus: ["*eight eyes open*", "*curls an arm toward you*", "*changes color curiously*", "...yes, friend?"],
22
+ penguin: ["*adjusts tie*", "*dignified waddle*", "*bows slightly*", "...yes, quite?"],
23
+ snail: ["*slow head extension*", "...mmm?", "*trails slowly toward you*", "*antenna twitches*"],
24
+ cactus: ["*stands silent*", "...hm.", "*spine twitches*", "*slowly rotates*"],
25
+ rabbit: ["*ears perk up*", "*nose twitches*", "yes?", "*hops closer*"],
26
+ mushroom: ["*releases a tiny spore*", "*cap tilts*", "*stands mysterious*", "...yes?"],
27
+ chonk: ["*barely opens one eye*", "...mrrp?", "*yawns heavily*", "*rolls over toward you*"],
28
+ };
29
+
30
+ const SUCCESS_REACTIONS: Partial<Record<Species, string[]>> = {
31
+ dragon: ["*nods, barely*", "...acceptable.", "*gold eyes gleam*", "as expected."],
32
+ owl: ["*satisfied hoot*", "knowledge confirmed.", "*nods sagely*", "as the tests have spoken."],
33
+ cat: ["*was never worried*", "*yawns*", "I knew you'd figure it out. eventually.", "*already asleep*"],
34
+ duck: ["*celebratory quacking*", "*waddles in circles*", "quack!", "*happy duck noises*"],
35
+ robot: ["OBJECTIVE: COMPLETE.", "*satisfying beep*", "NOMINAL.", "WITHIN ACCEPTABLE PARAMETERS."],
36
+ capybara: ["*maximum chill maintained*", "*nods once*", "good vibes.", "see? no panic needed."],
37
+ ghost: ["*drifts in quiet approval*", "not bad for the living.", "*soft spectral nod*", "the haunting may continue peacefully."],
38
+ axolotl: ["*happy gill flutter*", "*beams*", "you did it!", "*blushes pink*"],
39
+ blob: ["*jiggles happily*", "*gleams*", "yay!", "*bounces*"],
40
+ turtle: ["*satisfied shell settle*", "as the ancients foretold.", "*slow approving nod*", "good. very good."],
41
+ goose: ["*victorious honk*", "HONK OF APPROVAL.", "*struts triumphantly*", "*wing spread of victory*"],
42
+ octopus: ["*turns gentle blue*", "*arms applaud in sync*", "excellent, from all angles.", "*satisfied bubble*"],
43
+ penguin: ["*polite applause*", "quite good, quite good.", "*nods approvingly*", "splendid work, really."],
44
+ snail: ["*slow satisfied nod*", "good things take time.", "*leaves victory slime*", "see? no rush was needed."],
45
+ cactus: ["*blooms briefly*", "survival confirmed.", "*flowers in victory*", "*quiet bloom*"],
46
+ rabbit: ["*excited binky*", "*zoomies of joy*", "yay yay yay!", "*thumps in celebration*"],
47
+ mushroom: ["*spores of celebration*", "the mycelium approves.", "*cap brightens*", "spore of pride."],
48
+ chonk: ["*happy purr*", "*satisfied chonk noises*", "acceptable.", "*sleeps even harder*"] ,
49
+ };
50
+
51
+ const DEFAULT_NAME_REACTIONS = ["*perks up*", "...yes?", "*looks your way*"];
52
+ const DEFAULT_SUCCESS_REACTIONS = ["*nods*", "nice.", "*quiet approval*", "clean."];
53
+
9
54
  interface ReactionPool {
10
55
  [key: string]: string[];
11
56
  }
@@ -139,6 +184,16 @@ export function getReaction(
139
184
  return reaction;
140
185
  }
141
186
 
187
+ export function getNameReaction(species: Species): string {
188
+ const pool = NAME_REACTIONS[species] ?? DEFAULT_NAME_REACTIONS;
189
+ return pool[Math.floor(Math.random() * pool.length)];
190
+ }
191
+
192
+ export function getSuccessReaction(species: Species): string {
193
+ const pool = SUCCESS_REACTIONS[species] ?? DEFAULT_SUCCESS_REACTIONS;
194
+ return pool[Math.floor(Math.random() * pool.length)];
195
+ }
196
+
142
197
  // ─── Personality generation (fallback names when API unavailable) ────────────
143
198
 
144
199
  const FALLBACK_NAMES = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.4.0-alpha.4",
3
+ "version": "0.4.0-alpha.5",
4
4
  "description": "Persistent coding companion for Claude Code and pi",
5
5
  "type": "module",
6
6
  "bin": {