@ramarivera/coding-buddy 0.4.0-alpha.9 → 0.7.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +45 -0
  2. package/.claude-plugin/plugin.json +30 -0
  3. package/LICENSE +1 -1
  4. package/README.md +96 -45
  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 +130 -33
  22. package/cli/buddy-shell.ts +1 -1
  23. package/{adapters/claude/install → cli}/disable.ts +19 -32
  24. package/cli/doctor.ts +231 -0
  25. package/{adapters/claude/install → cli}/hunt.ts +7 -9
  26. package/cli/index.ts +30 -26
  27. package/{adapters/claude/install → cli}/install.ts +65 -83
  28. package/{adapters/claude/install → cli}/pick.ts +136 -57
  29. package/{adapters/claude/install → cli}/settings.ts +2 -2
  30. package/{adapters/claude/install → cli}/show.ts +4 -4
  31. package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
  32. package/{adapters/claude/install → cli}/test-statusline.ts +21 -11
  33. package/cli/tui.tsx +106 -17
  34. package/{adapters/claude/install → cli}/uninstall.ts +40 -47
  35. package/cli/upgrade.ts +46 -13
  36. package/cli/validate-species.ts +209 -0
  37. package/cli/verify.ts +3 -3
  38. package/core/achievements.ts +12 -20
  39. package/core/art-data.ts +26 -0
  40. package/core/command-service.ts +99 -97
  41. package/core/engine.ts +83 -1
  42. package/core/ports.ts +10 -0
  43. package/core/reactions.ts +533 -95
  44. package/core/slot-slug.ts +22 -0
  45. package/{adapters/claude/hooks → hooks}/buddy-comment.sh +22 -9
  46. package/hooks/file-type-react.sh +403 -0
  47. package/hooks/hooks.json +52 -0
  48. package/hooks/mood-react.sh +210 -0
  49. package/{adapters/claude/hooks → hooks}/name-react.sh +3 -4
  50. package/hooks/react.sh +1364 -0
  51. package/hooks/suggest.sh +55 -0
  52. package/package.json +70 -31
  53. package/scripts/gen-emoji-widths.ts +34 -0
  54. package/scripts/paths.sh +33 -2
  55. package/scripts/postinstall.cjs +42 -0
  56. package/scripts/snapshot-statusline.sh +54 -0
  57. package/server/achievements.ts +1419 -15
  58. package/server/art.ts +450 -1
  59. package/server/award-xp.ts +48 -0
  60. package/server/check-suggestions.ts +102 -0
  61. package/server/consolidate.ts +33 -0
  62. package/{adapters/claude/server → server}/index.ts +556 -95
  63. package/server/mcp-launcher.sh +16 -6
  64. package/server/memory.ts +404 -0
  65. package/server/mood.ts +254 -0
  66. package/server/path.ts +70 -18
  67. package/server/shift-mood.ts +43 -0
  68. package/server/state.ts +552 -3
  69. package/server/suggestions.ts +335 -0
  70. package/server/theme.ts +53 -0
  71. package/server/xp.ts +435 -0
  72. package/{adapters/claude/skills → skills}/buddy/SKILL.md +36 -7
  73. package/statusline/buddy-status.sh +402 -0
  74. package/statusline/combined-status.sh +135 -0
  75. package/statusline/emoji-widths.data +4 -0
  76. package/adapters/claude/hooks/hooks.json +0 -35
  77. package/adapters/claude/hooks/react.sh +0 -207
  78. package/adapters/claude/install/doctor.ts +0 -172
  79. package/adapters/claude/plugin/marketplace.json +0 -40
  80. package/adapters/claude/plugin/plugin.json +0 -26
  81. package/adapters/claude/rendering/art.ts +0 -264
  82. package/adapters/claude/server/instructions.ts +0 -24
  83. package/adapters/claude/server/path.ts +0 -12
  84. package/adapters/claude/server/resources.ts +0 -38
  85. package/adapters/claude/statusline/buddy-status.sh +0 -408
  86. package/adapters/claude/storage/achievements.ts +0 -253
  87. package/adapters/claude/storage/identity.ts +0 -13
  88. package/adapters/claude/storage/paths.ts +0 -33
  89. package/adapters/claude/storage/settings.ts +0 -91
  90. package/adapters/claude/storage/state.ts +0 -365
  91. package/adapters/pi/README.md +0 -83
  92. package/adapters/pi/tools.ts +0 -6
  93. package/server/engine.ts +0 -1
  94. package/server/reactions.ts +0 -1
@@ -1,296 +1,25 @@
1
- import { randomUUID } from "node:crypto";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- renameSync,
7
- writeFileSync,
8
- } from "node:fs";
9
- import { homedir } from "node:os";
1
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
10
2
  import { join } from "node:path";
11
3
  import {
12
- EMPTY_GLOBAL,
13
- EMPTY_SLOT,
14
- GLOBAL_KEYS,
15
- SLOT_KEYS,
16
- type GlobalCounters,
17
- type SlotCounters,
18
- } from "../../core/achievements.ts";
19
- import type {
20
- BuddyConfig,
21
- Companion,
22
- EventCounters,
23
- ReactionState,
24
- UnlockedAchievement,
25
- } from "../../core/model.ts";
26
- import type {
27
- BuddyConfigRepository,
28
- BuddyEventRepository,
29
- BuddyRepository,
30
- ReactionRepository,
31
- } from "../../core/ports.ts";
32
-
33
- const STATE_DIR = join(homedir(), ".pi", "agent", "buddy");
34
- const MENAGERIE_FILE = join(STATE_DIR, "menagerie.json");
35
- const CONFIG_FILE = join(STATE_DIR, "config.json");
36
- const REACTION_FILE = join(STATE_DIR, "reaction.json");
37
- const EVENTS_FILE = join(STATE_DIR, "events.json");
38
- const UNLOCKED_FILE = join(STATE_DIR, "unlocked.json");
39
- const ACTIVE_DAYS_FILE = join(STATE_DIR, "active_days.json");
40
- const IDENTITY_FILE = join(STATE_DIR, "identity.json");
41
-
42
- interface PiBuddyConfig extends BuddyConfig {
43
- muted: boolean;
44
- }
45
-
46
- interface MenagerieManifest {
47
- active: string;
48
- companions: Record<string, Companion>;
49
- }
50
-
51
- interface ActiveDayState {
52
- lastDate: string;
53
- totalDays: number;
54
- }
55
-
56
- const DEFAULT_CONFIG: PiBuddyConfig = {
57
- commentCooldown: 30,
58
- reactionTTL: 0,
59
- bubbleStyle: "classic",
60
- bubblePosition: "top",
61
- showRarity: true,
62
- statusLineEnabled: true,
63
- turnCommentModel: undefined,
64
- muted: false,
65
- };
66
-
67
- function ensureDir(): void {
68
- if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
69
- }
70
-
71
- function atomicWrite(path: string, value: string): void {
72
- ensureDir();
73
- const tmp = `${path}.tmp`;
74
- writeFileSync(tmp, value, "utf8");
75
- renameSync(tmp, path);
76
- }
77
-
78
- function readJson<T>(path: string, fallback: T): T {
79
- try {
80
- return { ...fallback, ...JSON.parse(readFileSync(path, "utf8")) };
81
- } catch {
82
- return fallback;
83
- }
84
- }
85
-
86
- function loadManifest(): MenagerieManifest {
87
- const manifest = readJson<MenagerieManifest>(MENAGERIE_FILE, {
88
- active: "buddy",
89
- companions: {},
90
- });
91
- if (!manifest.companions) manifest.companions = {};
92
- if (!manifest.active) manifest.active = Object.keys(manifest.companions)[0] ?? "buddy";
93
- return manifest;
94
- }
4
+ FileBuddyStorage,
5
+ slugifySlot,
6
+ type FileBuddyConfig,
7
+ } from "../shared/file-storage.ts";
95
8
 
96
- function saveManifest(manifest: MenagerieManifest): void {
97
- atomicWrite(MENAGERIE_FILE, JSON.stringify(manifest, null, 2));
98
- }
99
-
100
- function slotEventsFile(slot: string): string {
101
- return join(STATE_DIR, `events.${slot}.json`);
102
- }
103
-
104
- function isSlotKey(key: keyof EventCounters): key is keyof SlotCounters {
105
- return (SLOT_KEYS as readonly string[]).includes(key);
106
- }
107
-
108
- function isGlobalKey(key: keyof EventCounters): key is keyof GlobalCounters {
109
- return (GLOBAL_KEYS as readonly string[]).includes(key);
110
- }
111
-
112
- export class PiBuddyStorage
113
- implements
114
- BuddyRepository,
115
- ReactionRepository,
116
- BuddyConfigRepository,
117
- BuddyEventRepository
118
- {
119
- readonly stateDir = STATE_DIR;
120
-
121
- loadActive(): Companion | null {
122
- const manifest = loadManifest();
123
- return manifest.companions[manifest.active] ?? null;
124
- }
125
-
126
- saveActive(companion: Companion): void {
127
- const manifest = loadManifest();
128
- manifest.companions[manifest.active] = companion;
129
- saveManifest(manifest);
130
- }
131
-
132
- loadSlot(slot: string): Companion | null {
133
- return loadManifest().companions[slot] ?? null;
134
- }
135
-
136
- saveSlot(slot: string, companion: Companion): void {
137
- const manifest = loadManifest();
138
- if (manifest.companions[slot]) {
139
- throw new Error(`Slot \"${slot}\" already exists.`);
140
- }
141
- manifest.companions[slot] = companion;
142
- saveManifest(manifest);
143
- }
144
-
145
- deleteSlot(slot: string): void {
146
- const manifest = loadManifest();
147
- delete manifest.companions[slot];
148
- if (manifest.active === slot) {
149
- manifest.active = Object.keys(manifest.companions)[0] ?? "buddy";
150
- }
151
- saveManifest(manifest);
152
- }
9
+ export const DEFAULT_PI_BUDDY_STATE_DIR = join(getAgentDir(), "buddy");
153
10
 
154
- listSlots(): Array<{ slot: string; companion: Companion }> {
155
- return Object.entries(loadManifest().companions).map(([slot, companion]) => ({
156
- slot,
157
- companion,
158
- }));
11
+ export class PiBuddyStorage extends FileBuddyStorage {
12
+ constructor(stateDir = DEFAULT_PI_BUDDY_STATE_DIR) {
13
+ super(stateDir);
159
14
  }
160
15
 
161
- loadActiveSlot(): string | null {
162
- const manifest = loadManifest();
163
- if (manifest.active && manifest.companions[manifest.active]) return manifest.active;
164
- return Object.keys(manifest.companions)[0] ?? null;
16
+ loadPiConfig(): FileBuddyConfig {
17
+ return this.loadHostConfig();
165
18
  }
166
19
 
167
- saveActiveSlot(slot: string): void {
168
- const manifest = loadManifest();
169
- manifest.active = slot;
170
- saveManifest(manifest);
171
- }
172
-
173
- loadLatest(): ReactionState | null {
174
- try {
175
- const reaction = JSON.parse(readFileSync(REACTION_FILE, "utf8")) as ReactionState;
176
- const ttl = this.loadPiConfig().reactionTTL;
177
- if (ttl > 0 && Date.now() - reaction.timestamp > ttl * 1000) return null;
178
- return reaction;
179
- } catch {
180
- return null;
181
- }
182
- }
183
-
184
- saveLatest(reaction: ReactionState): void {
185
- atomicWrite(REACTION_FILE, JSON.stringify(reaction, null, 2));
186
- }
187
-
188
- clearLatestReaction(): void {
189
- atomicWrite(
190
- REACTION_FILE,
191
- JSON.stringify({ reaction: "", reason: "clear", timestamp: Date.now() }, null, 2),
192
- );
193
- }
194
-
195
- loadConfig(): BuddyConfig {
196
- return this.loadPiConfig();
197
- }
198
-
199
- saveConfig(config: Partial<BuddyConfig>): BuddyConfig {
200
- return this.savePiConfig(config);
201
- }
202
-
203
- loadPiConfig(): PiBuddyConfig {
204
- return readJson<PiBuddyConfig>(CONFIG_FILE, DEFAULT_CONFIG);
205
- }
206
-
207
- savePiConfig(config: Partial<PiBuddyConfig>): PiBuddyConfig {
208
- const merged = { ...this.loadPiConfig(), ...config };
209
- atomicWrite(CONFIG_FILE, JSON.stringify(merged, null, 2));
210
- return merged;
211
- }
212
-
213
- isMuted(): boolean {
214
- return this.loadPiConfig().muted;
215
- }
216
-
217
- setMuted(muted: boolean): PiBuddyConfig {
218
- return this.savePiConfig({ muted });
219
- }
220
-
221
- loadCounters(slot?: string): EventCounters {
222
- const global = readJson<GlobalCounters>(EVENTS_FILE, EMPTY_GLOBAL);
223
- if (!slot) {
224
- return { ...global, ...EMPTY_SLOT };
225
- }
226
- const slotCounters = readJson<SlotCounters>(slotEventsFile(slot), EMPTY_SLOT);
227
- return { ...global, ...slotCounters };
228
- }
229
-
230
- increment(key: keyof EventCounters, amount: number = 1, slot?: string): EventCounters {
231
- if (isSlotKey(key) && slot) {
232
- const slotCounters = readJson<SlotCounters>(slotEventsFile(slot), EMPTY_SLOT);
233
- slotCounters[key] += amount;
234
- atomicWrite(slotEventsFile(slot), JSON.stringify(slotCounters, null, 2));
235
- } else if (isGlobalKey(key)) {
236
- const global = readJson<GlobalCounters>(EVENTS_FILE, EMPTY_GLOBAL);
237
- global[key] += amount;
238
- atomicWrite(EVENTS_FILE, JSON.stringify(global, null, 2));
239
- }
240
-
241
- return this.loadCounters(slot);
242
- }
243
-
244
- loadUnlocked(): UnlockedAchievement[] {
245
- try {
246
- return JSON.parse(readFileSync(UNLOCKED_FILE, "utf8")) as UnlockedAchievement[];
247
- } catch {
248
- return [];
249
- }
250
- }
251
-
252
- saveUnlocked(unlocked: UnlockedAchievement[]): void {
253
- atomicWrite(UNLOCKED_FILE, JSON.stringify(unlocked, null, 2));
254
- }
255
-
256
- trackActiveDay(): void {
257
- const today = new Date().toISOString().slice(0, 10);
258
- const current = readJson<ActiveDayState>(ACTIVE_DAYS_FILE, {
259
- lastDate: "",
260
- totalDays: 0,
261
- });
262
- if (current.lastDate === today) return;
263
-
264
- const next = { lastDate: today, totalDays: current.totalDays + 1 };
265
- atomicWrite(ACTIVE_DAYS_FILE, JSON.stringify(next, null, 2));
266
-
267
- const global = readJson<GlobalCounters>(EVENTS_FILE, EMPTY_GLOBAL);
268
- global.days_active = next.totalDays;
269
- atomicWrite(EVENTS_FILE, JSON.stringify(global, null, 2));
270
- }
271
-
272
- ensureStableIdentity(): string {
273
- ensureDir();
274
- try {
275
- const parsed = JSON.parse(readFileSync(IDENTITY_FILE, "utf8")) as { userId?: string };
276
- if (parsed.userId) return parsed.userId;
277
- } catch {
278
- // ignore and create below
279
- }
280
-
281
- const userId = randomUUID();
282
- atomicWrite(IDENTITY_FILE, JSON.stringify({ userId }, null, 2));
283
- return userId;
20
+ savePiConfig(config: Partial<FileBuddyConfig>): FileBuddyConfig {
21
+ return this.saveHostConfig(config);
284
22
  }
285
23
  }
286
24
 
287
- export function slugifySlot(name: string): string {
288
- return (
289
- name
290
- .toLowerCase()
291
- .replace(/[^a-z0-9-]/g, "-")
292
- .replace(/-+/g, "-")
293
- .replace(/^-|-$/g, "")
294
- .slice(0, 14) || "buddy"
295
- );
296
- }
25
+ export { slugifySlot };
package/adapters/pi/ui.ts CHANGED
@@ -4,32 +4,71 @@ import type { Companion, ReactionState } from "../../core/model.ts";
4
4
  import { renderAchievementsSummary, renderBuddyStatus, renderBuddyWidget } from "./renderers.ts";
5
5
  import { PiBuddyStorage } from "./storage.ts";
6
6
 
7
+ type PiBuddyUiContext = ExtensionContext & {
8
+ setTimeout?: (callback: () => void, ms?: number) => Timer;
9
+ clearTimer?: (timer: Timer) => void;
10
+ };
11
+
7
12
  export class PiBuddyUI {
13
+ private readonly timers: Array<{ timer: Timer; clear: (timer: Timer) => void }> = [];
14
+
8
15
  constructor(private readonly storage: PiBuddyStorage) {}
9
16
 
17
+ cancelTimers(): void {
18
+ for (const { timer, clear } of this.timers.splice(0)) {
19
+ try {
20
+ clear(timer);
21
+ } catch {
22
+ // Ignore cleanup errors.
23
+ }
24
+ }
25
+ }
26
+
10
27
  refresh(
11
- ctx: ExtensionContext,
28
+ ctx: PiBuddyUiContext,
12
29
  companion: Companion,
13
30
  reaction?: ReactionState | null,
14
31
  achievements: Achievement[] = [],
15
32
  ): void {
33
+ const currentReaction = reaction ?? null;
16
34
  const muted = this.storage.isMuted();
17
- const status = renderBuddyStatus(companion, muted ? null : reaction);
35
+ const status = renderBuddyStatus(companion, muted ? null : currentReaction);
18
36
  ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
19
37
  ctx.ui.setWidget(
20
38
  "buddy",
21
- renderBuddyWidget(companion, muted ? null : reaction, achievements),
39
+ renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
22
40
  );
41
+
42
+ this.cancelTimers();
43
+ if (!currentReaction) return;
44
+
45
+ const ttl = this.storage.loadConfig().reactionTTL;
46
+ if (ttl <= 0) return;
47
+
48
+ const scheduler = getScheduler(ctx);
49
+ if (!scheduler) return;
50
+
51
+ const reactionTimestamp = currentReaction.timestamp;
52
+ const delay = Math.max(0, reactionTimestamp + ttl * 1000 + 1 - Date.now());
53
+ const timer = scheduler.setTimeout(() => {
54
+ const latest = this.storage.loadLatest();
55
+ if (latest && latest.timestamp >= reactionTimestamp) {
56
+ this.refresh(ctx, companion, latest, []);
57
+ } else {
58
+ this.refresh(ctx, companion, null, []);
59
+ }
60
+ }, delay);
61
+ this.timers.push({ timer, clear: scheduler.clearTimer });
23
62
  }
24
63
 
25
- notifyAchievements(ctx: ExtensionContext, achievements: Achievement[]): void {
64
+ notifyAchievements(ctx: PiBuddyUiContext, achievements: Achievement[]): void {
26
65
  for (const achievement of achievements) {
27
66
  ctx.ui.notify(`Unlocked: ${achievement.icon} ${achievement.name}`, "info");
28
67
  }
29
68
  }
30
69
 
31
70
  showAchievements(
32
- ctx: ExtensionContext,
71
+ ctx: PiBuddyUiContext,
33
72
  unlocked: Array<{ achievement: Achievement; unlockedAt: number; slot?: string }>,
34
73
  remaining: Achievement[],
35
74
  ): void {
@@ -37,3 +76,15 @@ export class PiBuddyUI {
37
76
  ctx.ui.notify(`Achievements: ${unlocked.length} unlocked`, "info");
38
77
  }
39
78
  }
79
+
80
+ function getScheduler(
81
+ ctx: PiBuddyUiContext,
82
+ ): { setTimeout: (callback: () => void, ms?: number) => Timer; clearTimer: (timer: Timer) => void } | null {
83
+ if (typeof ctx.setTimeout === "function" && typeof ctx.clearTimer === "function") {
84
+ return { setTimeout: ctx.setTimeout, clearTimer: ctx.clearTimer };
85
+ }
86
+ if (typeof globalThis.setTimeout === "function" && typeof globalThis.clearTimeout === "function") {
87
+ return { setTimeout: globalThis.setTimeout, clearTimer: globalThis.clearTimeout };
88
+ }
89
+ return null;
90
+ }