@ramarivera/coding-buddy 0.4.0-alpha.8 → 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.
Files changed (92) hide show
  1. package/{adapters/claude/plugin → .claude-plugin}/marketplace.json +11 -6
  2. package/{adapters/claude/plugin → .claude-plugin}/plugin.json +9 -7
  3. package/LICENSE +1 -1
  4. package/README.md +102 -72
  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 +59 -44
  22. package/cli/biomes.ts +309 -0
  23. package/cli/buddy-shell.ts +818 -0
  24. package/{adapters/claude/install → cli}/disable.ts +14 -32
  25. package/{adapters/claude/install → cli}/doctor.ts +55 -57
  26. package/{adapters/claude/install → cli}/hunt.ts +7 -9
  27. package/cli/index.ts +36 -25
  28. package/{adapters/claude/install → cli}/install.ts +100 -145
  29. package/{adapters/claude/install → cli}/pick.ts +111 -50
  30. package/{adapters/claude/install → cli}/settings.ts +2 -2
  31. package/{adapters/claude/install → cli}/show.ts +4 -4
  32. package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
  33. package/{adapters/claude/install → cli}/test-statusline.ts +11 -11
  34. package/cli/tui.tsx +2333 -0
  35. package/{adapters/claude/install → cli}/uninstall.ts +23 -38
  36. package/cli/upgrade.ts +213 -0
  37. package/cli/validate-species.ts +209 -0
  38. package/cli/verify.ts +3 -3
  39. package/core/achievements.ts +12 -20
  40. package/core/art-data.ts +26 -0
  41. package/core/command-service.ts +99 -97
  42. package/core/engine.ts +83 -1
  43. package/core/ports.ts +10 -0
  44. package/core/reactions.ts +533 -95
  45. package/core/slot-slug.ts +22 -0
  46. package/{adapters/claude/hooks → hooks}/buddy-comment.sh +23 -2
  47. package/hooks/file-type-react.sh +401 -0
  48. package/hooks/hooks.json +52 -0
  49. package/hooks/mood-react.sh +208 -0
  50. package/{adapters/claude/hooks → hooks}/name-react.sh +5 -2
  51. package/hooks/react.sh +1368 -0
  52. package/hooks/suggest.sh +55 -0
  53. package/package.json +113 -59
  54. package/scripts/gen-emoji-widths.ts +34 -0
  55. package/scripts/paths.sh +56 -0
  56. package/scripts/postinstall.cjs +42 -0
  57. package/scripts/snapshot-statusline.sh +54 -0
  58. package/server/achievements.ts +1419 -0
  59. package/server/art.ts +450 -0
  60. package/server/award-xp.ts +48 -0
  61. package/server/check-suggestions.ts +102 -0
  62. package/server/consolidate.ts +33 -0
  63. package/server/index.ts +1340 -0
  64. package/server/mcp-launcher.sh +26 -0
  65. package/server/memory.ts +404 -0
  66. package/server/mood.ts +254 -0
  67. package/server/path.ts +82 -0
  68. package/server/shift-mood.ts +43 -0
  69. package/{adapters/claude/storage → server}/state.ts +200 -7
  70. package/server/suggestions.ts +335 -0
  71. package/server/theme.ts +53 -0
  72. package/server/xp.ts +435 -0
  73. package/{adapters/claude/skills → skills}/buddy/SKILL.md +48 -4
  74. package/statusline/buddy-status.sh +397 -0
  75. package/statusline/combined-status.sh +134 -0
  76. package/statusline/emoji-widths.data +4 -0
  77. package/adapters/claude/hooks/hooks.json +0 -35
  78. package/adapters/claude/hooks/react.sh +0 -204
  79. package/adapters/claude/popup/buddy-popup.sh +0 -92
  80. package/adapters/claude/popup/buddy-render.sh +0 -540
  81. package/adapters/claude/popup/popup-manager.sh +0 -355
  82. package/adapters/claude/rendering/art.ts +0 -264
  83. package/adapters/claude/server/index.ts +0 -752
  84. package/adapters/claude/server/instructions.ts +0 -24
  85. package/adapters/claude/server/resources.ts +0 -38
  86. package/adapters/claude/statusline/buddy-status.sh +0 -389
  87. package/adapters/claude/storage/achievements.ts +0 -253
  88. package/adapters/claude/storage/identity.ts +0 -13
  89. package/adapters/claude/storage/paths.ts +0 -24
  90. package/adapters/claude/storage/settings.ts +0 -41
  91. package/adapters/pi/README.md +0 -83
  92. package/adapters/pi/tools.ts +0 -6
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # coding-buddy MCP server launcher — preflight checks bun, then execs the server.
3
+ # Failing here with a clear stderr message is much easier to debug than a silent
4
+ # "MCP tools not available" from Claude Code.
5
+
6
+ set -eu
7
+
8
+ if ! command -v bun >/dev/null 2>&1; then
9
+ cat >&2 <<'EOF'
10
+ [coding-buddy] ERROR: 'bun' was not found on PATH.
11
+
12
+ coding-buddy's MCP server runs on bun. Install it with:
13
+
14
+ Linux / macOS:
15
+ curl -fsSL https://bun.sh/install | bash
16
+
17
+ Windows (PowerShell):
18
+ powershell -c "irm bun.sh/install.ps1 | iex"
19
+
20
+ Then open a new shell (so PATH picks up bun) and restart Claude Code.
21
+ EOF
22
+ exit 127
23
+ fi
24
+
25
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
26
+ exec bun "$SCRIPT_DIR/index.ts"
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Cross-session memory — remembers past projects and earlier conversations.
3
+ *
4
+ * Memory is stored in ~/.claude-buddy/memory/:
5
+ * projects.json — project records
6
+ * bugs.json — encountered bugs
7
+ * preferences.json — user preferences
8
+ *
9
+ * All data is local-only. Nothing is sent to any remote service.
10
+ * Privacy: users can wipe with `rm -rf ~/.claude-buddy/memory`.
11
+ */
12
+
13
+ import {
14
+ readFileSync,
15
+ writeFileSync,
16
+ mkdirSync,
17
+ existsSync,
18
+ } from "fs";
19
+ import { join } from "path";
20
+ import { buddyStateDir } from "./path";
21
+
22
+ // ─── Types ───────────────────────────────────────────────────────────────────
23
+
24
+ export interface ProjectMemory {
25
+ id: string;
26
+ name: string;
27
+ path: string;
28
+ language: string[]; // e.g. ["typescript", "python"]
29
+ framework?: string;
30
+ lastSeen: number; // timestamp
31
+ noteCount: number;
32
+ tags: string[];
33
+ fileCount?: number; // number of files in project
34
+ }
35
+
36
+ export interface BugMemory {
37
+ id: string;
38
+ projectId: string;
39
+ errorSignature: string; // hash of error message
40
+ summary: string;
41
+ resolved: boolean;
42
+ resolvedAt?: number;
43
+ occurrenceCount: number;
44
+ lastSeen: number;
45
+ firstSeen: number;
46
+ }
47
+
48
+ export interface PreferenceMemory {
49
+ key: string;
50
+ value: string;
51
+ context: string;
52
+ confidence: number; // 0-1, derived from frequency
53
+ lastUpdated: number;
54
+ }
55
+
56
+ // ─── File paths ───────────────────────────────────────────────────────────────
57
+
58
+ const MEMORY_DIR = join(buddyStateDir(), "memory");
59
+ const PROJECTS_FILE = join(MEMORY_DIR, "projects.json");
60
+ const BUGS_FILE = join(MEMORY_DIR, "bugs.json");
61
+ const PREFERENCES_FILE = join(MEMORY_DIR, "preferences.json");
62
+
63
+ function ensureDir(): void {
64
+ mkdirSync(MEMORY_DIR, { recursive: true });
65
+ }
66
+
67
+ function loadJson<T>(path: string, fallback: T): T {
68
+ try {
69
+ if (existsSync(path)) {
70
+ return JSON.parse(readFileSync(path, "utf8")) as T;
71
+ }
72
+ } catch {
73
+ // corrupted or empty — use fallback
74
+ }
75
+ return fallback;
76
+ }
77
+
78
+ function saveJson<T>(path: string, data: T): void {
79
+ ensureDir();
80
+ writeFileSync(path, JSON.stringify(data, null, 2));
81
+ }
82
+
83
+ // ─── Project memory ──────────────────────────────────────────────────────────
84
+
85
+ export function loadProjects(): Record<string, ProjectMemory> {
86
+ return loadJson(PROJECTS_FILE, {});
87
+ }
88
+
89
+ export function saveProjects(projects: Record<string, ProjectMemory>): void {
90
+ saveJson(PROJECTS_FILE, projects);
91
+ }
92
+
93
+ export function getProject(id: string): ProjectMemory | null {
94
+ const projects = loadProjects();
95
+ return projects[id] ?? null;
96
+ }
97
+
98
+ export function updateProject(
99
+ id: string,
100
+ updates: Partial<Omit<ProjectMemory, "id">>,
101
+ ): ProjectMemory {
102
+ const projects = loadProjects();
103
+ const existing = projects[id] ?? {
104
+ id,
105
+ name: id,
106
+ path: "",
107
+ language: [],
108
+ lastSeen: Date.now(),
109
+ noteCount: 0,
110
+ tags: [],
111
+ };
112
+ projects[id] = {
113
+ ...existing,
114
+ ...updates,
115
+ id, // always preserve original id
116
+ lastSeen: Date.now(),
117
+ };
118
+ saveProjects(projects);
119
+ return projects[id];
120
+ }
121
+
122
+ // ─── Bug memory ──────────────────────────────────────────────────────────────
123
+
124
+ export function loadBugs(): Record<string, BugMemory> {
125
+ return loadJson(BUGS_FILE, {});
126
+ }
127
+
128
+ export function saveBugs(bugs: Record<string, BugMemory>): void {
129
+ saveJson(BUGS_FILE, bugs);
130
+ }
131
+
132
+ export function getBug(id: string): BugMemory | null {
133
+ const bugs = loadBugs();
134
+ return bugs[id] ?? null;
135
+ }
136
+
137
+ /** Find a bug by its error signature hash within a project */
138
+ export function findBugBySignature(
139
+ projectId: string,
140
+ errorSignature: string,
141
+ ): BugMemory | null {
142
+ const bugs = loadBugs();
143
+ return Object.values(bugs).find(
144
+ (b) => b.projectId === projectId && b.errorSignature === errorSignature,
145
+ ) ?? null;
146
+ }
147
+
148
+ export function addBugMemory(
149
+ projectId: string,
150
+ errorSignature: string,
151
+ summary: string,
152
+ ): BugMemory {
153
+ const bugs = loadBugs();
154
+
155
+ // Check if this bug already exists
156
+ const existing = findBugBySignature(projectId, errorSignature);
157
+ if (existing) {
158
+ existing.occurrenceCount += 1;
159
+ existing.lastSeen = Date.now();
160
+ existing.summary = summary; // update summary with latest
161
+ saveBugs(bugs);
162
+ return existing;
163
+ }
164
+
165
+ const id = `${projectId}-${errorSignature.slice(0, 8)}`;
166
+ const bug: BugMemory = {
167
+ id,
168
+ projectId,
169
+ errorSignature,
170
+ summary,
171
+ resolved: false,
172
+ occurrenceCount: 1,
173
+ lastSeen: Date.now(),
174
+ firstSeen: Date.now(),
175
+ };
176
+ bugs[id] = bug;
177
+ saveBugs(bugs);
178
+ return bug;
179
+ }
180
+
181
+ export function resolveBug(id: string): BugMemory | null {
182
+ const bugs = loadBugs();
183
+ const bug = bugs[id];
184
+ if (!bug) return null;
185
+ bug.resolved = true;
186
+ bug.resolvedAt = Date.now();
187
+ saveBugs(bugs);
188
+ return bug;
189
+ }
190
+
191
+ // ─── Preference memory ─────────────────────────────────────────────────────────
192
+
193
+ export function loadPreferences(): Record<string, PreferenceMemory> {
194
+ return loadJson(PREFERENCES_FILE, {});
195
+ }
196
+
197
+ export function savePreferences(prefs: Record<string, PreferenceMemory>): void {
198
+ saveJson(PREFERENCES_FILE, prefs);
199
+ }
200
+
201
+ export function setPreference(
202
+ key: string,
203
+ value: string,
204
+ context: string,
205
+ confidenceBoost: number = 0.1,
206
+ ): PreferenceMemory {
207
+ const prefs = loadPreferences();
208
+ const existing = prefs[key];
209
+
210
+ if (existing && existing.context === context) {
211
+ // Same context — increase confidence, update value
212
+ existing.confidence = Math.min(1, existing.confidence + confidenceBoost);
213
+ existing.value = value;
214
+ existing.lastUpdated = Date.now();
215
+ } else {
216
+ // New context or different context — create new or merge cautiously
217
+ const newConf = confidenceBoost;
218
+ if (existing && existing.confidence > newConf) {
219
+ // Existing is more confident — don't overwrite
220
+ existing.lastUpdated = Date.now();
221
+ } else {
222
+ prefs[key] = {
223
+ key,
224
+ value,
225
+ context,
226
+ confidence: newConf,
227
+ lastUpdated: Date.now(),
228
+ };
229
+ }
230
+ }
231
+
232
+ savePreferences(prefs);
233
+ return prefs[key];
234
+ }
235
+
236
+ // ─── Consolidation ───────────────────────────────────────────────────────────
237
+
238
+ /** Simple hash function for error signatures */
239
+ function simpleHash(s: string): string {
240
+ let h = 0;
241
+ for (let i = 0; i < s.length; i++) {
242
+ const char = s.charCodeAt(i);
243
+ h = ((h << 5) - h) + char;
244
+ h = h & h; // convert to 32-bit integer
245
+ }
246
+ return Math.abs(h).toString(16);
247
+ }
248
+
249
+ /** Extract language signals from text (file extensions) */
250
+ function extractLanguages(text: string): string[] {
251
+ const extMap: Record<string, string> = {
252
+ ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
253
+ py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
254
+ php: "php", c: "c", cpp: "cpp", cs: "csharp", swift: "swift",
255
+ kt: "kotlin", scala: "scala", r: "r", lua: "lua", pl: "perl",
256
+ sh: "shell", bash: "shell", zsh: "shell", fish: "shell",
257
+ sql: "sql", html: "html", css: "css", scss: "scss", json: "json",
258
+ yaml: "yaml", yml: "yaml", toml: "toml", xml: "xml", md: "markdown",
259
+ };
260
+
261
+ const found = new Set<string>();
262
+ const extRegex = /\.([a-z]+)/gi;
263
+ let match;
264
+ while ((match = extRegex.exec(text)) !== null) {
265
+ const ext = match[1].toLowerCase();
266
+ const lang = extMap[ext];
267
+ if (lang) found.add(lang);
268
+ }
269
+ return [...found];
270
+ }
271
+
272
+ /** Extract package manager / framework signals */
273
+ function extractFramework(text: string): string | undefined {
274
+ const signals: Record<string, string> = {
275
+ "package.json": "Node.js", "package-lock.json": "Node.js",
276
+ "requirements.txt": "Python", "Pipfile": "Python", "pyproject.toml": "Python",
277
+ "Cargo.toml": "Rust", "Cargo.lock": "Rust",
278
+ "go.mod": "Go", "go.sum": "Go",
279
+ "pom.xml": "Java", "build.gradle": "Java",
280
+ "Gemfile": "Ruby", "Gemfile.lock": "Ruby",
281
+ "composer.json": "PHP",
282
+ "swiftpm": "Swift", "Package.swift": "Swift",
283
+ ".NET": ".NET", "csproj": ".NET",
284
+ };
285
+
286
+ for (const [file, framework] of Object.entries(signals)) {
287
+ if (text.includes(file)) return framework;
288
+ }
289
+ return undefined;
290
+ }
291
+
292
+ /** Extract project name from path or git remote */
293
+ function extractProjectName(text: string): string | undefined {
294
+ // Try to find a directory path
295
+ const pathRegex = /\/([^\/]+)\/(?:src|lib|app|packages|tests?)/gi;
296
+ let match;
297
+ while ((match = pathRegex.exec(text)) !== null) {
298
+ return match[1];
299
+ }
300
+
301
+ // Try git remote
302
+ const gitRegex = /github\.com[:/]([\w\-\.]+)\/([^\/\s]+)/gi;
303
+ while ((match = gitRegex.exec(text)) !== null) {
304
+ return match[2];
305
+ }
306
+
307
+ return undefined;
308
+ }
309
+
310
+ /** Extract error messages from text */
311
+ function extractErrors(text: string): Array<{ signature: string; summary: string }> {
312
+ const errors: Array<{ signature: string; summary: string }> = [];
313
+ // Common error patterns
314
+ const errorPatterns = [
315
+ /([A-Z][a-z]+Error: .+)/,
316
+ /(SyntaxError: .+)/,
317
+ /(TypeError: .+)/,
318
+ /(ReferenceError: .+)/,
319
+ /(Error: .+)/,
320
+ /(panic: .+)/,
321
+ /(Exception: .+)/,
322
+ ];
323
+
324
+ for (const pattern of errorPatterns) {
325
+ let match;
326
+ const regex = new RegExp(pattern, "gi");
327
+ while ((match = regex.exec(text)) !== null) {
328
+ const full = match[0];
329
+ const signature = simpleHash(full);
330
+ // Truncate summary for storage
331
+ const summary = full.slice(0, 200);
332
+ errors.push({ signature, summary });
333
+ }
334
+ }
335
+
336
+ return errors;
337
+ }
338
+
339
+ /**
340
+ * Consolidate memory from a conversation turn.
341
+ * Called on Stop hook with the assistant's response and user prompt.
342
+ */
343
+ export function consolidateMemory(
344
+ assistantMessage: string,
345
+ userPrompt: string,
346
+ ): void {
347
+ const combined = `${assistantMessage} ${userPrompt}`;
348
+
349
+ // Extract project name
350
+ const projectName = extractProjectName(combined);
351
+ if (!projectName) return;
352
+
353
+ // Extract and update project
354
+ const languages = extractLanguages(combined);
355
+ const framework = extractFramework(combined);
356
+ updateProject(projectName, {
357
+ name: projectName,
358
+ language: languages.length > 0 ? languages : undefined,
359
+ framework,
360
+ });
361
+
362
+ // Extract and store bugs
363
+ const errors = extractErrors(combined);
364
+ for (const err of errors) {
365
+ addBugMemory(projectName, err.signature, err.summary);
366
+ }
367
+ }
368
+
369
+ // ─── Retrieval ───────────────────────────────────────────────────────────────
370
+
371
+ export interface MemoryQuery {
372
+ project?: string;
373
+ type?: "projects" | "bugs" | "preferences" | "all";
374
+ resolved?: boolean; // for bugs
375
+ }
376
+
377
+ export function queryMemory(query: MemoryQuery): {
378
+ projects: ProjectMemory[];
379
+ bugs: BugMemory[];
380
+ preferences: PreferenceMemory[];
381
+ } {
382
+ const projects = query.type === "bugs" || query.type === "preferences"
383
+ ? []
384
+ : Object.values(loadProjects()).filter(
385
+ (p) => !query.project || p.id === query.project || p.name.includes(query.project),
386
+ );
387
+
388
+ let bugs = query.type === "projects" || query.type === "preferences"
389
+ ? []
390
+ : Object.values(loadBugs());
391
+
392
+ if (query.project) {
393
+ bugs = bugs.filter((b) => b.projectId === query.project);
394
+ }
395
+ if (query.resolved !== undefined) {
396
+ bugs = bugs.filter((b) => b.resolved === query.resolved);
397
+ }
398
+
399
+ const preferences = query.type === "projects" || query.type === "bugs"
400
+ ? []
401
+ : Object.values(loadPreferences());
402
+
403
+ return { projects, bugs, preferences };
404
+ }
package/server/mood.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Mood system — buddy情绪 based on coding events and time of day.
3
+ *
4
+ * Mood is session-scoped (stored in mood.json, resets on session restart).
5
+ * Shifts based on: test results, error count, session duration, time of day.
6
+ * Affects: reaction flavor, stat modifiers, art expression.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
10
+ import { join } from "path";
11
+ import { buddyStateDir } from "./path";
12
+ import type { StatName } from "../core/engine.ts"
13
+
14
+ // ─── Mood types ───────────────────────────────────────────────────────────────
15
+
16
+ export type Mood =
17
+ | "happy" // tests passing, good code
18
+ | "focused" // active coding session, good progress
19
+ | "excited" // big diff, achievement unlocked, major event
20
+ | "tired" // long session, many errors
21
+ | "melancholy" // repeated failures, low streak
22
+ | "chaotic"; // many errors in short time, unpredictable
23
+
24
+ export interface MoodState {
25
+ current: Mood;
26
+ since: number; // timestamp of last mood change
27
+ intensity: number; // 1-3, affects how much mood modifies behavior
28
+ recentErrors: number; // errors in last 5 minutes
29
+ recentTests: number; // tests passed in last 5 minutes
30
+ recentDiffs: number; // large diffs in last 5 minutes
31
+ }
32
+
33
+ // ─── Mood colors (for status line display) ───────────────────────────────────
34
+
35
+ export const MOOD_COLORS: Record<Mood, string> = {
36
+ happy: "\ud83d\ude0a", // 😀
37
+ focused: "\ud83e\udd13", // 🧐
38
+ excited: "\ud83e\ude74", // 🥳
39
+ tired: "\ud83d\ude34", // 😴
40
+ melancholy: "\ud83d\ude14", // 😔
41
+ chaotic: "\ud83e\udd2f", // 🤯
42
+ };
43
+
44
+ export const MOOD_NAMES: Record<Mood, string> = {
45
+ happy: "Happy",
46
+ focused: "Focused",
47
+ excited: "Excited",
48
+ tired: "Tired",
49
+ melancholy: "Melancholy",
50
+ chaotic: "Chaotic",
51
+ };
52
+
53
+ // ─── Time-of-day influence ───────────────────────────────────────────────────
54
+
55
+ export function getTimeOfDayMood(): Mood {
56
+ const hour = new Date().getHours();
57
+ if (hour >= 22 || hour < 6) return "tired"; // late night
58
+ if (hour >= 6 && hour < 9) return "melancholy"; // early morning
59
+ if (hour >= 9 && hour < 12) return "focused"; // morning
60
+ if (hour >= 12 && hour < 17) return "happy"; // afternoon
61
+ if (hour >= 17 && hour < 20) return "excited"; // evening
62
+ return "tired";
63
+ }
64
+
65
+ // ─── Mood persistence ────────────────────────────────────────────────────────
66
+
67
+ const MOOD_FILE = join(buddyStateDir(), "mood.json");
68
+
69
+ function loadMoodState(): MoodState {
70
+ try {
71
+ return JSON.parse(readFileSync(MOOD_FILE, "utf8")) as MoodState;
72
+ } catch {
73
+ return {
74
+ current: getTimeOfDayMood(),
75
+ since: Date.now(),
76
+ intensity: 1,
77
+ recentErrors: 0,
78
+ recentTests: 0,
79
+ recentDiffs: 0,
80
+ };
81
+ }
82
+ }
83
+
84
+ function saveMoodState(state: MoodState): void {
85
+ mkdirSync(buddyStateDir(), { recursive: true });
86
+ writeFileSync(MOOD_FILE, JSON.stringify(state, null, 2));
87
+ }
88
+
89
+ // ─── Mood shift triggers ────────────────────────────────────────────────────
90
+
91
+ export type MoodTrigger =
92
+ | "tests_pass"
93
+ | "tests_fail"
94
+ | "error"
95
+ | "large_diff"
96
+ | "achievement"
97
+ | "long_session"
98
+ | "idle"
99
+ | "rest";
100
+
101
+ const MOOD_TRANSITIONS: Record<Mood, Partial<Record<MoodTrigger, Mood>>> = {
102
+ happy: {
103
+ tests_pass: "excited",
104
+ achievement: "excited",
105
+ long_session: "melancholy",
106
+ error: "tired",
107
+ },
108
+ focused: {
109
+ tests_pass: "happy",
110
+ error: "tired",
111
+ long_session: "tired",
112
+ },
113
+ excited: {
114
+ tests_pass: "excited",
115
+ error: "focused",
116
+ idle: "happy",
117
+ },
118
+ tired: {
119
+ tests_pass: "focused",
120
+ error: "chaotic",
121
+ rest: "happy",
122
+ idle: "melancholy",
123
+ },
124
+ melancholy: {
125
+ tests_pass: "focused",
126
+ achievement: "happy",
127
+ error: "chaotic",
128
+ rest: "happy",
129
+ },
130
+ chaotic: {
131
+ tests_pass: "focused",
132
+ idle: "tired",
133
+ rest: "melancholy",
134
+ error: "chaotic",
135
+ },
136
+ };
137
+
138
+ // ─── Core functions ───────────────────────────────────────────────────────────
139
+
140
+ const RECENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
141
+
142
+ function decayRecentCounts(state: MoodState): MoodState {
143
+ // Decay recent counts based on time since last update
144
+ const elapsed = Date.now() - state.since;
145
+ if (elapsed > RECENT_WINDOW_MS) {
146
+ return {
147
+ ...state,
148
+ recentErrors: 0,
149
+ recentTests: 0,
150
+ recentDiffs: 0,
151
+ since: Date.now(),
152
+ };
153
+ }
154
+ return state;
155
+ }
156
+
157
+ /**
158
+ * Shift mood based on a trigger event.
159
+ * Returns the new mood state.
160
+ */
161
+ export function shiftMood(trigger: MoodTrigger): MoodState {
162
+ let state = loadMoodState();
163
+ state = decayRecentCounts(state);
164
+
165
+ // Update recent counters based on trigger
166
+ switch (trigger) {
167
+ case "error":
168
+ state.recentErrors += 1;
169
+ break;
170
+ case "tests_pass":
171
+ state.recentTests += 1;
172
+ break;
173
+ case "large_diff":
174
+ state.recentDiffs += 1;
175
+ break;
176
+ }
177
+
178
+ // Check for chaotic threshold (3+ errors in 5 minutes)
179
+ if (trigger === "error" && state.recentErrors >= 3) {
180
+ state.current = "chaotic";
181
+ state.intensity = Math.min(3, state.recentErrors - 1);
182
+ state.since = Date.now();
183
+ saveMoodState(state);
184
+ return state;
185
+ }
186
+
187
+ // Look up the transition for the current mood
188
+ const transitions = MOOD_TRANSITIONS[state.current];
189
+ if (transitions && transitions[trigger]) {
190
+ const newMood = transitions[trigger]!;
191
+ if (newMood !== state.current) {
192
+ state.current = newMood;
193
+ state.intensity = Math.min(3, state.intensity + 1);
194
+ state.since = Date.now();
195
+ }
196
+ }
197
+
198
+ saveMoodState(state);
199
+ return state;
200
+ }
201
+
202
+ /**
203
+ * Get the current mood state.
204
+ */
205
+ export function getMood(): MoodState {
206
+ return decayRecentCounts(loadMoodState());
207
+ }
208
+
209
+ /**
210
+ * Get mood stat modifiers (±5 to effective stats based on mood).
211
+ * These are temporary and don't persist.
212
+ */
213
+ export function getMoodModifier(mood: Mood, stat: StatName): number {
214
+ switch (mood) {
215
+ case "happy":
216
+ return stat === "WISDOM" ? +5 : 0;
217
+ case "focused":
218
+ return stat === "DEBUGGING" ? +5 : stat === "CHAOS" ? -3 : 0;
219
+ case "chaotic":
220
+ return stat === "CHAOS" ? +8 : stat === "WISDOM" ? -5 : 0;
221
+ case "tired":
222
+ return stat === "PATIENCE" ? +3 : stat === "DEBUGGING" ? -3 : 0;
223
+ case "melancholy":
224
+ return stat === "WISDOM" ? +3 : 0;
225
+ case "excited":
226
+ return stat === "CHAOS" ? +5 : stat === "PATIENCE" ? -3 : 0;
227
+ }
228
+ }
229
+
230
+ // ─── Mood for reactions ─────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Get a mood-specific reaction suffix/prefix.
234
+ * Returns undefined if no mood-specific reaction should be used.
235
+ */
236
+ export function getMoodReaction(mood: Mood): string | undefined {
237
+ const r = Math.random();
238
+ if (r > 0.3) return undefined; // 30% chance of mood reaction
239
+
240
+ switch (mood) {
241
+ case "happy":
242
+ return "*blooms*";
243
+ case "focused":
244
+ return "*intensifies*";
245
+ case "excited":
246
+ return "*vibrates with energy*";
247
+ case "tired":
248
+ return "*yawns*";
249
+ case "melancholy":
250
+ return "*sighs gently*";
251
+ case "chaotic":
252
+ return "*glitches*";
253
+ }
254
+ }