@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
package/core/achievements.ts
CHANGED
|
@@ -1,24 +1,6 @@
|
|
|
1
|
-
import type { UnlockedAchievement } from "./model.ts";
|
|
1
|
+
import type { UnlockedAchievement, GlobalCounters, SlotCounters, EventCounters } from "./model.ts";
|
|
2
|
+
export type { GlobalCounters, SlotCounters, EventCounters };
|
|
2
3
|
|
|
3
|
-
export interface GlobalCounters {
|
|
4
|
-
errors_seen: number;
|
|
5
|
-
tests_failed: number;
|
|
6
|
-
large_diffs: number;
|
|
7
|
-
sessions: number;
|
|
8
|
-
commands_run: number;
|
|
9
|
-
days_active: number;
|
|
10
|
-
turns: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface SlotCounters {
|
|
14
|
-
pets: number;
|
|
15
|
-
reactions_given: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface EventCounters extends GlobalCounters {
|
|
19
|
-
pets: number;
|
|
20
|
-
reactions_given: number;
|
|
21
|
-
}
|
|
22
4
|
|
|
23
5
|
export const GLOBAL_KEYS: (keyof GlobalCounters)[] = [
|
|
24
6
|
"errors_seen", "tests_failed", "large_diffs",
|
|
@@ -189,12 +171,22 @@ export function getUnlockedAchievements(
|
|
|
189
171
|
): Achievement[] {
|
|
190
172
|
const newlyUnlocked: Achievement[] = [];
|
|
191
173
|
for (const ach of ACHIEVEMENTS) {
|
|
174
|
+
if (ach.id === "first_steps") continue;
|
|
192
175
|
if (unlockedIds.has(ach.id)) continue;
|
|
193
176
|
if (ach.check(events)) newlyUnlocked.push(ach);
|
|
194
177
|
}
|
|
195
178
|
return newlyUnlocked;
|
|
196
179
|
}
|
|
197
180
|
|
|
181
|
+
export function getCreationAchievements(
|
|
182
|
+
_events: EventCounters,
|
|
183
|
+
unlockedIds: Set<string>,
|
|
184
|
+
): Achievement[] {
|
|
185
|
+
if (unlockedIds.has("first_steps")) return [];
|
|
186
|
+
const first = ACHIEVEMENTS.find((ach) => ach.id === "first_steps");
|
|
187
|
+
return first ? [first] : [];
|
|
188
|
+
}
|
|
189
|
+
|
|
198
190
|
export function isAchievementUnlocked(
|
|
199
191
|
id: string,
|
|
200
192
|
unlocked: UnlockedAchievement[],
|
package/core/art-data.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Species, Hat } from "./engine.ts";
|
|
2
2
|
|
|
3
|
+
// ─── Species art: 3 frames × 5 lines each ──────────────────────────────────
|
|
4
|
+
|
|
3
5
|
export const SPECIES_ART: Record<Species, string[][]> = {
|
|
4
6
|
duck: [
|
|
5
7
|
[" ", " __ ", " <({E} )___ ", " ( ._> ", " `--' "],
|
|
@@ -91,8 +93,32 @@ export const SPECIES_ART: Record<Species, string[][]> = {
|
|
|
91
93
|
[" ", " /\\ /| ", " ( {E} {E} ) ", " ( .. ) ", " `------' "],
|
|
92
94
|
[" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------'~ "],
|
|
93
95
|
],
|
|
96
|
+
wyvern: [
|
|
97
|
+
["} {",
|
|
98
|
+
"|\\^```^/|",
|
|
99
|
+
"\\ {E}' '{E} /",
|
|
100
|
+
" ≈(° °)≈",
|
|
101
|
+
" '-'"],
|
|
102
|
+
["} {",
|
|
103
|
+
"|\\^```^/|",
|
|
104
|
+
"\\ {E}' '{E} /",
|
|
105
|
+
" ≈(° °)≈",
|
|
106
|
+
" '~'"],
|
|
107
|
+
["} {",
|
|
108
|
+
"|\\^```^/|",
|
|
109
|
+
"\\ {E}' '{E} /",
|
|
110
|
+
" ≈(° °)≈",
|
|
111
|
+
" 'v'"],
|
|
112
|
+
],
|
|
113
|
+
pikachu: [
|
|
114
|
+
[" ", " /\\_/\\ ", " ({E} {E}) ", " ( ω ) ", " (__) "],
|
|
115
|
+
[" ", " /\\_/\\ ", " (- -) ", " ( ω ) ", " (__) "],
|
|
116
|
+
[" ", " /\\_/\\ ", " ({E} {E}) ", " ( ~ ) ", " (__) "],
|
|
117
|
+
],
|
|
94
118
|
};
|
|
95
119
|
|
|
120
|
+
// ─── Hat art ────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
96
122
|
export const HAT_ART: Record<Hat, string> = {
|
|
97
123
|
none: "",
|
|
98
124
|
crown: " \\^^^/ ",
|
package/core/command-service.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
export function mergeAchievements(first: Achievement[], second: Achievement[]): Achievement[] {
|
|
2
|
+
const merged = new Map<string, Achievement>();
|
|
3
|
+
for (const achievement of [...first, ...second]) {
|
|
4
|
+
merged.set(achievement.id, achievement);
|
|
5
|
+
}
|
|
6
|
+
return [...merged.values()];
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
import {
|
|
2
10
|
generateBones,
|
|
3
11
|
RARITY_STARS,
|
|
4
12
|
type Companion,
|
|
5
13
|
} from "./engine.ts";
|
|
6
|
-
import { getUnlockedAchievements, type Achievement } from "./achievements.ts";
|
|
14
|
+
import { ACHIEVEMENTS, getCreationAchievements, getUnlockedAchievements, type Achievement } from "./achievements.ts";
|
|
7
15
|
import { generateFallbackName, getReaction } from "./reactions.ts";
|
|
16
|
+
import { slugifySlot } from "./slot-slug.ts";
|
|
8
17
|
import type {
|
|
9
18
|
BuddyConfig,
|
|
10
19
|
ReactionState,
|
|
@@ -86,40 +95,27 @@ export class BuddyCommandService {
|
|
|
86
95
|
}
|
|
87
96
|
|
|
88
97
|
ensureCompanion(): EnsureCompanionResult {
|
|
89
|
-
const active = this.deps.buddies.loadActive();
|
|
90
|
-
if (active) {
|
|
91
|
-
return {
|
|
92
|
-
companion: active,
|
|
93
|
-
slot: this.getActiveSlot(),
|
|
94
|
-
created: false,
|
|
95
|
-
achievements: [],
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const saved = this.deps.buddies.listSlots();
|
|
100
|
-
if (saved.length > 0) {
|
|
101
|
-
const rescued = saved[0];
|
|
102
|
-
this.deps.buddies.saveActiveSlot(rescued.slot);
|
|
103
|
-
return {
|
|
104
|
-
companion: rescued.companion,
|
|
105
|
-
slot: rescued.slot,
|
|
106
|
-
created: false,
|
|
107
|
-
achievements: [],
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
98
|
const userId = this.getStableUserId();
|
|
112
|
-
const companion = this.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
99
|
+
const { companion, slot, created } = this.deps.buddies.ensureCompanion(
|
|
100
|
+
userId,
|
|
101
|
+
(existingSlots) => this.createCompanion(userId, existingSlots),
|
|
102
|
+
);
|
|
103
|
+
const achievements = created
|
|
104
|
+
? this.deps.events.unlockAchievements(slot, getCreationAchievements)
|
|
105
|
+
: [];
|
|
106
|
+
return { companion, slot, created, achievements };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
startSession(): EnsureCompanionResult {
|
|
110
|
+
const result = this.ensureCompanion();
|
|
116
111
|
this.deps.events.trackActiveDay();
|
|
117
112
|
this.deps.events.increment("sessions", 1);
|
|
118
|
-
const
|
|
113
|
+
const sessionAchievements = this.unlockNewAchievements(result.slot);
|
|
119
114
|
|
|
120
|
-
return {
|
|
115
|
+
return { ...result, achievements: mergeAchievements(result.achievements, sessionAchievements) };
|
|
121
116
|
}
|
|
122
117
|
|
|
118
|
+
|
|
123
119
|
petBuddy(scope?: string): ReactionResult {
|
|
124
120
|
const { companion, slot } = this.ensureCompanion();
|
|
125
121
|
this.deps.events.increment("pets", 1, slot);
|
|
@@ -129,31 +125,45 @@ export class BuddyCommandService {
|
|
|
129
125
|
renameBuddy(name: string): Companion {
|
|
130
126
|
const trimmed = name.trim();
|
|
131
127
|
if (!trimmed) throw new Error("Buddy name cannot be empty.");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
128
|
+
this.ensureCompanion();
|
|
129
|
+
return this.deps.buddies.updateActive((companion) => ({
|
|
130
|
+
...companion,
|
|
131
|
+
name: trimmed.slice(0, 14),
|
|
132
|
+
}));
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
setPersonality(personality: string): Companion {
|
|
139
136
|
const trimmed = personality.trim();
|
|
140
137
|
if (!trimmed) throw new Error("Buddy personality cannot be empty.");
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
this.ensureCompanion();
|
|
139
|
+
return this.deps.buddies.updateActive((companion) => ({
|
|
140
|
+
...companion,
|
|
141
|
+
personality: trimmed,
|
|
142
|
+
}));
|
|
145
143
|
}
|
|
146
144
|
|
|
147
145
|
saveBuddy(slot?: string): SaveBuddyResult {
|
|
148
|
-
const { companion } = this.ensureCompanion();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
const { companion, slot: activeSlot } = this.ensureCompanion();
|
|
147
|
+
if (slot?.trim()) {
|
|
148
|
+
const targetSlot = slugifySlot(slot.trim());
|
|
149
|
+
if (targetSlot !== activeSlot && this.deps.buddies.loadSlot(targetSlot)) {
|
|
150
|
+
throw new Error(`Slot "${targetSlot}" already exists.`);
|
|
151
|
+
}
|
|
152
|
+
if (targetSlot === activeSlot) {
|
|
153
|
+
this.deps.buddies.saveActive(companion);
|
|
154
|
+
} else {
|
|
155
|
+
this.deps.buddies.saveSlot(targetSlot, companion);
|
|
156
|
+
}
|
|
157
|
+
this.deps.buddies.saveActiveSlot(targetSlot);
|
|
158
|
+
return { companion, slot: targetSlot };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.deps.buddies.saveActive(companion);
|
|
162
|
+
return { companion, slot: activeSlot };
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
summonBuddy(slot?: string): SummonBuddyResult | null {
|
|
156
|
-
const targetSlot = slot ?
|
|
166
|
+
const targetSlot = slot ? slugifySlot(slot) : undefined;
|
|
157
167
|
|
|
158
168
|
if (!targetSlot) {
|
|
159
169
|
const companions = this.deps.buddies.listSlots();
|
|
@@ -171,7 +181,7 @@ export class BuddyCommandService {
|
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
dismissBuddy(slot: string): SaveBuddyResult {
|
|
174
|
-
const targetSlot =
|
|
184
|
+
const targetSlot = slugifySlot(slot);
|
|
175
185
|
const activeSlot = this.getActiveSlot();
|
|
176
186
|
if (targetSlot === activeSlot) {
|
|
177
187
|
throw new Error("Cannot dismiss the active buddy.");
|
|
@@ -208,6 +218,23 @@ export class BuddyCommandService {
|
|
|
208
218
|
this.deps.events.increment("large_diffs", 1);
|
|
209
219
|
return this.saveReaction("large-diff", companion, slot, scope, { lines });
|
|
210
220
|
}
|
|
221
|
+
trackToolError(scope?: string, line?: number): Achievement[] {
|
|
222
|
+
const { slot } = this.ensureCompanion();
|
|
223
|
+
this.deps.events.increment("errors_seen", 1, slot);
|
|
224
|
+
return this.unlockNewAchievements(slot);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
trackTestFailure(scope?: string, count?: number): Achievement[] {
|
|
228
|
+
const { slot } = this.ensureCompanion();
|
|
229
|
+
this.deps.events.increment("tests_failed", 1, slot);
|
|
230
|
+
return this.unlockNewAchievements(slot);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
trackLargeDiff(lines: number, scope?: string): Achievement[] {
|
|
234
|
+
const { slot } = this.ensureCompanion();
|
|
235
|
+
this.deps.events.increment("large_diffs", 1, slot);
|
|
236
|
+
return this.unlockNewAchievements(slot);
|
|
237
|
+
}
|
|
211
238
|
|
|
212
239
|
recordTurn(scope?: string): ReactionResult {
|
|
213
240
|
const { companion, slot } = this.ensureCompanion();
|
|
@@ -218,7 +245,7 @@ export class BuddyCommandService {
|
|
|
218
245
|
recordTurnOnly(): ProgressResult {
|
|
219
246
|
const { companion, slot } = this.ensureCompanion();
|
|
220
247
|
this.deps.events.increment("turns", 1);
|
|
221
|
-
const achievements = this.
|
|
248
|
+
const achievements = this.unlockNewAchievements(slot);
|
|
222
249
|
return { companion, slot, achievements };
|
|
223
250
|
}
|
|
224
251
|
|
|
@@ -232,10 +259,10 @@ export class BuddyCommandService {
|
|
|
232
259
|
const { companion, slot } = this.ensureCompanion();
|
|
233
260
|
return this.saveCustomReaction(trimmed, reason, companion, slot, scope);
|
|
234
261
|
}
|
|
235
|
-
|
|
236
262
|
incrementCommandsRun(): Achievement[] {
|
|
237
|
-
|
|
238
|
-
|
|
263
|
+
const slot = this.getActiveSlotOrUndefined();
|
|
264
|
+
this.deps.events.increment("commands_run", 1, slot);
|
|
265
|
+
return this.unlockNewAchievements(slot);
|
|
239
266
|
}
|
|
240
267
|
|
|
241
268
|
getAchievementProgress(slot?: string): {
|
|
@@ -245,15 +272,10 @@ export class BuddyCommandService {
|
|
|
245
272
|
const unlocked = this.deps.events.loadUnlocked();
|
|
246
273
|
const unlockedMap = new Map(unlocked.map((entry) => [entry.id, entry]));
|
|
247
274
|
|
|
248
|
-
const unlockedAchievements =
|
|
249
|
-
|
|
250
|
-
new Set<string>(),
|
|
251
|
-
).filter((achievement) => unlockedMap.has(achievement.id));
|
|
252
|
-
|
|
253
|
-
const remaining = getUnlockedAchievements(
|
|
254
|
-
this.deps.events.loadCounters(slot),
|
|
255
|
-
new Set(unlocked.map((entry) => entry.id)),
|
|
275
|
+
const unlockedAchievements = ACHIEVEMENTS.filter((achievement) =>
|
|
276
|
+
unlockedMap.has(achievement.id)
|
|
256
277
|
);
|
|
278
|
+
const remaining = ACHIEVEMENTS.filter((achievement) => !unlockedMap.has(achievement.id));
|
|
257
279
|
|
|
258
280
|
return {
|
|
259
281
|
unlocked: unlockedAchievements.map((achievement) => ({
|
|
@@ -279,23 +301,30 @@ export class BuddyCommandService {
|
|
|
279
301
|
return slot || undefined;
|
|
280
302
|
}
|
|
281
303
|
|
|
282
|
-
private createCompanion(
|
|
304
|
+
private createCompanion(
|
|
305
|
+
userId: string,
|
|
306
|
+
existingSlots: string[],
|
|
307
|
+
): { companion: Companion; slot: string } {
|
|
283
308
|
const bones = generateBones(userId);
|
|
284
|
-
const name = this.generateUnusedName();
|
|
309
|
+
const name = this.generateUnusedName(existingSlots);
|
|
310
|
+
const slot = slugifySlot(name);
|
|
285
311
|
return {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
312
|
+
companion: {
|
|
313
|
+
bones,
|
|
314
|
+
name,
|
|
315
|
+
personality: `A ${bones.rarity} ${bones.species} who watches code with quiet intensity.`,
|
|
316
|
+
hatchedAt: Date.now(),
|
|
317
|
+
userId,
|
|
318
|
+
},
|
|
319
|
+
slot,
|
|
291
320
|
};
|
|
292
321
|
}
|
|
293
322
|
|
|
294
|
-
private generateUnusedName(): string {
|
|
295
|
-
const taken = new Set(
|
|
323
|
+
private generateUnusedName(existingSlots: string[]): string {
|
|
324
|
+
const taken = new Set(existingSlots);
|
|
296
325
|
for (let i = 0; i < 50; i++) {
|
|
297
326
|
const candidate = generateFallbackName();
|
|
298
|
-
if (!taken.has(
|
|
327
|
+
if (!taken.has(slugifySlot(candidate))) return candidate;
|
|
299
328
|
}
|
|
300
329
|
|
|
301
330
|
let suffix = 0;
|
|
@@ -314,6 +343,7 @@ export class BuddyCommandService {
|
|
|
314
343
|
reason,
|
|
315
344
|
companion.bones.species,
|
|
316
345
|
companion.bones.rarity,
|
|
346
|
+
companion.bones.stats,
|
|
317
347
|
context,
|
|
318
348
|
);
|
|
319
349
|
return this.saveCustomReaction(reaction, reason, companion, slot, scope);
|
|
@@ -333,40 +363,12 @@ export class BuddyCommandService {
|
|
|
333
363
|
};
|
|
334
364
|
this.deps.reactions.saveLatest(state, scope);
|
|
335
365
|
this.deps.events.increment("reactions_given", 1, slot);
|
|
336
|
-
const achievements = this.
|
|
366
|
+
const achievements = this.unlockNewAchievements(slot);
|
|
337
367
|
return { companion, slot, reaction, state, achievements };
|
|
338
368
|
}
|
|
339
369
|
|
|
340
|
-
private
|
|
341
|
-
|
|
342
|
-
const unlockedIds = new Set(unlocked.map((entry) => entry.id));
|
|
343
|
-
const newlyUnlocked = getUnlockedAchievements(
|
|
344
|
-
this.deps.events.loadCounters(slot),
|
|
345
|
-
unlockedIds,
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
if (newlyUnlocked.length === 0) return [];
|
|
349
|
-
|
|
350
|
-
this.deps.events.saveUnlocked([
|
|
351
|
-
...unlocked,
|
|
352
|
-
...newlyUnlocked.map((achievement) => ({
|
|
353
|
-
id: achievement.id,
|
|
354
|
-
unlockedAt: Date.now(),
|
|
355
|
-
slot,
|
|
356
|
-
})),
|
|
357
|
-
]);
|
|
358
|
-
|
|
359
|
-
return newlyUnlocked;
|
|
370
|
+
private unlockNewAchievements(slot?: string): Achievement[] {
|
|
371
|
+
return this.deps.events.unlockAchievements(slot, getUnlockedAchievements);
|
|
360
372
|
}
|
|
361
|
-
}
|
|
362
373
|
|
|
363
|
-
function slugify(name: string): string {
|
|
364
|
-
return (
|
|
365
|
-
name
|
|
366
|
-
.toLowerCase()
|
|
367
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
368
|
-
.replace(/-+/g, "-")
|
|
369
|
-
.replace(/^-|-$/g, "")
|
|
370
|
-
.slice(0, 14) || "buddy"
|
|
371
|
-
);
|
|
372
374
|
}
|
package/core/engine.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* coding-buddy engine — deterministic companion generation
|
|
3
3
|
* Matches Claude Code's exact algorithm: wyhash → mulberry32 → species/stats
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -24,6 +24,8 @@ export const SPECIES = [
|
|
|
24
24
|
"rabbit",
|
|
25
25
|
"mushroom",
|
|
26
26
|
"chonk",
|
|
27
|
+
"wyvern",
|
|
28
|
+
"pikachu",
|
|
27
29
|
] as const;
|
|
28
30
|
|
|
29
31
|
export type Species = (typeof SPECIES)[number];
|
|
@@ -296,6 +298,84 @@ export function generateBones(userId: string, salt: string = SALT): BuddyBones {
|
|
|
296
298
|
return { rarity, species, eye, hat, shiny, stats, peak, dump };
|
|
297
299
|
}
|
|
298
300
|
|
|
301
|
+
// ─── Personality ────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
const PEAK_PHRASES: Record<StatName, string[]> = {
|
|
304
|
+
DEBUGGING: [
|
|
305
|
+
"spots segfaults before the stack unwinds",
|
|
306
|
+
"can read a backtrace like a map",
|
|
307
|
+
"finds the off-by-one before the tests do",
|
|
308
|
+
],
|
|
309
|
+
PATIENCE: [
|
|
310
|
+
"outlasts any flaky test suite",
|
|
311
|
+
"waits for the slow CI build without complaint",
|
|
312
|
+
"never merges before the green check",
|
|
313
|
+
],
|
|
314
|
+
CHAOS: [
|
|
315
|
+
"treats breaking changes as a love language",
|
|
316
|
+
"rewrites the Makefile on a Tuesday for fun",
|
|
317
|
+
"thrives wherever the incident channel is loudest",
|
|
318
|
+
],
|
|
319
|
+
WISDOM: [
|
|
320
|
+
"has seen this bug before — in three other repos",
|
|
321
|
+
"quotes the relevant RFC from memory",
|
|
322
|
+
"recognizes the abstraction that will outlive its author",
|
|
323
|
+
],
|
|
324
|
+
SNARK: [
|
|
325
|
+
"leaves code review comments that linger",
|
|
326
|
+
"finds the edge case you forgot and mentions it twice",
|
|
327
|
+
"names things with uncomfortable accuracy",
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const DUMP_PHRASES: Record<StatName, string[]> = {
|
|
332
|
+
DEBUGGING: [
|
|
333
|
+
"occasionally ships the workaround instead of the fix",
|
|
334
|
+
"skips the repro step",
|
|
335
|
+
"trusts the logs a little too much",
|
|
336
|
+
],
|
|
337
|
+
PATIENCE: [
|
|
338
|
+
"starts the rebase before the review is done",
|
|
339
|
+
"has been known to close slow issues as stale",
|
|
340
|
+
"has been known to force-push main",
|
|
341
|
+
],
|
|
342
|
+
CHAOS: [
|
|
343
|
+
"prefers everything to stay exactly where it is",
|
|
344
|
+
"dislikes surprise refactors",
|
|
345
|
+
"writes very thorough migration guides",
|
|
346
|
+
],
|
|
347
|
+
WISDOM: [
|
|
348
|
+
"sometimes reinvents the wheel with enthusiasm",
|
|
349
|
+
"skips the existing prior art",
|
|
350
|
+
"learns by doing, not by reading",
|
|
351
|
+
],
|
|
352
|
+
SNARK: [
|
|
353
|
+
"only leaves encouraging comments",
|
|
354
|
+
"approves PRs with genuine warmth",
|
|
355
|
+
"never says what it actually thinks",
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const RARITY_CLOSER: Record<string, string[]> = {
|
|
360
|
+
common: ["Gets the job done.", "Reliable, if unassuming."],
|
|
361
|
+
uncommon: ["Has a few tricks up its sleeve.", "Worth keeping around."],
|
|
362
|
+
rare: ["Not to be underestimated.", "Earns its keep."],
|
|
363
|
+
epic: ["Commands quiet respect.", "The kind of companion repos are built around."],
|
|
364
|
+
legendary: ["The kind you find once, if you're lucky.", "Leaves every codebase better than it found it."],
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export function generatePersonality(bones: BuddyBones, userId: string): string {
|
|
368
|
+
const seed = parseInt(userId.slice(0, 8), 16);
|
|
369
|
+
const pickPhrase = <T>(arr: T[], salt: number = 0): T => arr[(seed + salt) % arr.length];
|
|
370
|
+
|
|
371
|
+
const shiny = bones.shiny ? " Shimmers faintly in dark mode." : "";
|
|
372
|
+
const peak = pickPhrase(PEAK_PHRASES[bones.peak]);
|
|
373
|
+
const dump = pickPhrase(DUMP_PHRASES[bones.dump], 1);
|
|
374
|
+
const closer = pickPhrase(RARITY_CLOSER[bones.rarity] ?? ["Gets the job done."], 2);
|
|
375
|
+
|
|
376
|
+
return `A ${bones.rarity} ${bones.species} that ${peak}.${shiny} ${closer} Though it ${dump}.`;
|
|
377
|
+
}
|
|
378
|
+
|
|
299
379
|
// ─── ASCII Art ──────────────────────────────────────────────────────────────
|
|
300
380
|
|
|
301
381
|
const FACE_TEMPLATES: Record<Species, string> = {
|
|
@@ -317,6 +397,8 @@ const FACE_TEMPLATES: Record<Species, string> = {
|
|
|
317
397
|
rabbit: "({E}..{E})",
|
|
318
398
|
mushroom: "|{E} {E}|",
|
|
319
399
|
chonk: "({E}.{E})",
|
|
400
|
+
wyvern: "\\ {E}' '{E} /",
|
|
401
|
+
pikachu: "({E}ω{E})",
|
|
320
402
|
};
|
|
321
403
|
|
|
322
404
|
export function renderFace(species: Species, eye: Eye): string {
|
package/core/ports.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ReactionState,
|
|
6
6
|
UnlockedAchievement,
|
|
7
7
|
} from "./model.ts";
|
|
8
|
+
import type { Achievement } from "./achievements.ts";
|
|
8
9
|
|
|
9
10
|
export interface IdentityProvider {
|
|
10
11
|
getStableUserId(): string;
|
|
@@ -13,12 +14,17 @@ export interface IdentityProvider {
|
|
|
13
14
|
export interface BuddyRepository {
|
|
14
15
|
loadActive(): Companion | null;
|
|
15
16
|
saveActive(companion: Companion): void;
|
|
17
|
+
updateActive(transform: (companion: Companion) => Companion): Companion;
|
|
16
18
|
loadSlot(slot: string): Companion | null;
|
|
17
19
|
saveSlot(slot: string, companion: Companion): void;
|
|
18
20
|
deleteSlot(slot: string): void;
|
|
19
21
|
listSlots(): Array<{ slot: string; companion: Companion }>;
|
|
20
22
|
loadActiveSlot(): string | null;
|
|
21
23
|
saveActiveSlot(slot: string): void;
|
|
24
|
+
ensureCompanion(
|
|
25
|
+
userId: string,
|
|
26
|
+
create: (existingSlots: string[]) => { companion: Companion; slot: string },
|
|
27
|
+
): { companion: Companion; slot: string; created: boolean };
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export interface ReactionRepository {
|
|
@@ -37,4 +43,8 @@ export interface BuddyEventRepository {
|
|
|
37
43
|
loadUnlocked(): UnlockedAchievement[];
|
|
38
44
|
saveUnlocked(unlocked: UnlockedAchievement[]): void;
|
|
39
45
|
trackActiveDay(): void;
|
|
46
|
+
unlockAchievements(
|
|
47
|
+
slot: string | undefined,
|
|
48
|
+
evaluate: (counters: EventCounters, unlockedIds: Set<string>) => Achievement[],
|
|
49
|
+
): Achievement[];
|
|
40
50
|
}
|