opencode-buddy 0.2.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.
@@ -0,0 +1,399 @@
1
+ // Species library: 6 ASCII animals × multiple states × animation frames.
2
+ //
3
+ // Frame format: array of strings, each string is one row. All frames in a
4
+ // state are the same width/height, so the renderer can swap them at a fixed
5
+ // cadence without resizing the canvas.
6
+
7
+ const RESET = "\x1b[0m";
8
+
9
+ const C = {
10
+ reset: RESET,
11
+ dim: "\x1b[2m",
12
+ bold: "\x1b[1m",
13
+ red: "\x1b[31m",
14
+ green: "\x1b[32m",
15
+ yellow: "\x1b[33m",
16
+ blue: "\x1b[34m",
17
+ magenta: "\x1b[35m",
18
+ cyan: "\x1b[36m",
19
+ gray: "\x1b[90m",
20
+ brightRed: "\x1b[91m",
21
+ brightGreen: "\x1b[92m",
22
+ brightYellow: "\x1b[93m",
23
+ brightBlue: "\x1b[94m",
24
+ brightMagenta: "\x1b[95m",
25
+ brightCyan: "\x1b[96m",
26
+ bgYellow: "\x1b[43m",
27
+ bgRed: "\x1b[41m",
28
+ };
29
+
30
+ // Each frame is a multiline string. Keep them all the same dimensions
31
+ // across states of the same species so the canvas doesn't jitter.
32
+ const RAW = {
33
+ duck: {
34
+ idle: [
35
+ ` __ `,
36
+ ` <(o )___ `,
37
+ ` ( ._> / `,
38
+ ` \`--' `,
39
+ ` `,
40
+ ` ~ idle ~ `,
41
+ ],
42
+ working: [
43
+ ` __ `,
44
+ ` <(o )___ `,
45
+ ` ( ._> / `,
46
+ ` *\`--'* .. .. `,
47
+ ` / / `,
48
+ ` coding... `,
49
+ ],
50
+ celebrating: [
51
+ ` \\o/ `,
52
+ ` <(o )___ ! `,
53
+ ` ( ._> / `,
54
+ ` \`--' \\o/ `,
55
+ ` `,
56
+ ` \\o/ nice! `,
57
+ ],
58
+ scared: [
59
+ ` __ `,
60
+ ` <(O )___ `,
61
+ ` ( ._> / `,
62
+ ` \`--' !! `,
63
+ ` `,
64
+ ` !! oh no `,
65
+ ],
66
+ sleeping: [
67
+ ` __ `,
68
+ ` <(o )___ `,
69
+ ` ( -_> z `,
70
+ ` \`--' z `,
71
+ ` `,
72
+ ` z z z z z `,
73
+ ],
74
+ },
75
+
76
+ cat: {
77
+ idle: [
78
+ ` /\\_/\\ `,
79
+ ` ( o.o ) `,
80
+ ` > ^ < `,
81
+ ` /| |\\ `,
82
+ ` (_| |_) `,
83
+ ` meow `,
84
+ ],
85
+ working: [
86
+ ` /\\_/\\ ... `,
87
+ ` ( o.o ) `,
88
+ ` > ^ < `,
89
+ ` /| |\\ .. `,
90
+ ` (_| |_) `,
91
+ ` typing... `,
92
+ ],
93
+ celebrating: [
94
+ ` /\\_/\\ `,
95
+ ` ( ^.^ ) \\o/ `,
96
+ ` > ~ < `,
97
+ ` /| |\\ \\o/ `,
98
+ ` (_| |_) `,
99
+ ` purrrfect! `,
100
+ ],
101
+ scared: [
102
+ ` /\\_/\\ !! `,
103
+ ` ( O.O ) `,
104
+ ` > ^ < `,
105
+ ` /| |\\ `,
106
+ ` (_| |_) /\\ `,
107
+ ` !! hiss `,
108
+ ],
109
+ sleeping: [
110
+ ` /\\_/\\ `,
111
+ ` ( -.- ) z `,
112
+ ` > ^ < `,
113
+ ` /| |\\ z `,
114
+ ` (_| |_) `,
115
+ ` z z z z z `,
116
+ ],
117
+ },
118
+
119
+ dragon: {
120
+ idle: [
121
+ ` /\\_/\\ `,
122
+ ` ( o o ) ~~ `,
123
+ ` > ^ < / `,
124
+ ` /| |\\ `,
125
+ ` (_| |_) `,
126
+ ` rawr `,
127
+ ],
128
+ working: [
129
+ ` /\\_/\\ ~~~ `,
130
+ ` ( o o ) ~~ `,
131
+ ` > ^ < `,
132
+ ` /| |\\ ~~ `,
133
+ ` (_| |_) `,
134
+ ` forging... `,
135
+ ],
136
+ celebrating: [
137
+ ` /\\^/\\ \\o/ `,
138
+ ` ( ^.^ ) `,
139
+ ` > ~ < \\o/ `,
140
+ ` /| |\\ `,
141
+ ` (_| |_) `,
142
+ ` +50 xp!! \\o/ `,
143
+ ],
144
+ scared: [
145
+ ` /\\_/\\ !! `,
146
+ ` ( O O ) `,
147
+ ` > ^ < `,
148
+ ` /| |\\ !! `,
149
+ ` (_| |_) `,
150
+ ` !! yikes `,
151
+ ],
152
+ sleeping: [
153
+ ` /\\_/\\ `,
154
+ ` ( - - ) z `,
155
+ ` > ^ < `,
156
+ ` /| |\\ z `,
157
+ ` (_| |_) `,
158
+ ` z z z z z `,
159
+ ],
160
+ },
161
+
162
+ axolotl: {
163
+ idle: [
164
+ ` ^___^ `,
165
+ ` (o . o) `,
166
+ ` \\|_|_|/ `,
167
+ ` \\| |/ `,
168
+ ` ) ( `,
169
+ ` ~ ambien `,
170
+ ],
171
+ working: [
172
+ ` ^___^ .. `,
173
+ ` (o . o) `,
174
+ ` \\|_|_|/ .. `,
175
+ ` \\| |/ `,
176
+ ` ) ( `,
177
+ ` swimming... `,
178
+ ],
179
+ celebrating: [
180
+ ` ^___^ \\o/ `,
181
+ ` (o ^ o) `,
182
+ ` \\|_|_|/ \\o/ `,
183
+ ` \\| |/ `,
184
+ ` ) ( `,
185
+ ` \\o/ splish! `,
186
+ ],
187
+ scared: [
188
+ ` ^___^ !! `,
189
+ ` (O . O) `,
190
+ ` \\|_|_|/ `,
191
+ ` \\| |/ !! `,
192
+ ` ) ( `,
193
+ ` !! gulp `,
194
+ ],
195
+ sleeping: [
196
+ ` ^___^ `,
197
+ ` (o - o) z `,
198
+ ` \\|_|_|/ `,
199
+ ` \\| |/ z `,
200
+ ` ) ( `,
201
+ ` z z z z z `,
202
+ ],
203
+ },
204
+
205
+ robot: {
206
+ idle: [
207
+ ` [ O . O ] `,
208
+ ` /|#####|\\ `,
209
+ ` / |#####| \\ `,
210
+ ` | | `,
211
+ ` /| | | |\\ `,
212
+ ` beep `,
213
+ ],
214
+ working: [
215
+ ` [ O . O ] ... `,
216
+ ` /|#####|\\ `,
217
+ ` / |#####| \\ .. `,
218
+ ` | | `,
219
+ ` /| | | |\\ `,
220
+ ` compiling... `,
221
+ ],
222
+ celebrating: [
223
+ ` [ ^ . ^ ] \\o/ `,
224
+ ` /|#####|\\ `,
225
+ ` / |#####| \\ `,
226
+ ` | | `,
227
+ ` /| | | |\\ \\o/ `,
228
+ ` pass tests! `,
229
+ ],
230
+ scared: [
231
+ ` [ O . O ] !! `,
232
+ ` /|#####|\\ `,
233
+ ` / |#####| \\ `,
234
+ ` | | !! `,
235
+ ` /| | | |\\ `,
236
+ ` !! segfault `,
237
+ ],
238
+ sleeping: [
239
+ ` [ - . - ] z `,
240
+ ` /|#####|\\ `,
241
+ ` / |#####| \\ z `,
242
+ ` | | `,
243
+ ` /| | | |\\ `,
244
+ ` z z z z z `,
245
+ ],
246
+ },
247
+
248
+ ghost: {
249
+ idle: [
250
+ ` .-\"\"-. `,
251
+ ` ( o . o ) `,
252
+ ` | ~ ~ | `,
253
+ ` | | `,
254
+ ` \\uuuuu/ `,
255
+ ` boo `,
256
+ ],
257
+ working: [
258
+ ` .-\"\"-. ... `,
259
+ ` ( o . o ) `,
260
+ ` | ~ ~ | `,
261
+ ` | | `,
262
+ ` \\uuuuu/ `,
263
+ ` haunting... `,
264
+ ],
265
+ celebrating: [
266
+ ` .-\"\"-. \\o/ `,
267
+ ` ( ^ . ^ ) `,
268
+ ` | ~ ~ | \\o/ `,
269
+ ` | | `,
270
+ ` \\uuuuu/ `,
271
+ ` \\o/ spooky! `,
272
+ ],
273
+ scared: [
274
+ ` .-\"\"-. !! `,
275
+ ` ( O . O ) `,
276
+ ` | ~ ~ | `,
277
+ ` | | !! `,
278
+ ` \\uuuuu/ `,
279
+ ` !! yeek `,
280
+ ],
281
+ sleeping: [
282
+ ` .-\"\"-. `,
283
+ ` ( - . - ) z `,
284
+ ` | ~ ~ | `,
285
+ ` | | `,
286
+ ` \\uuuuu/ z `,
287
+ ` z z z z z `,
288
+ ],
289
+ },
290
+ };
291
+
292
+ // Per-character color map. Each species has a function that takes a char
293
+ // from the raw frame and returns an ANSI-colored version. This way we
294
+ // can give ducks a yellow bill, axolotls pink gills, ghosts a translucent
295
+ // hue, etc.
296
+ const PALETTES = {
297
+ duck: (ch) => {
298
+ if (ch === "o" || ch === "O") return C.brightYellow + ch + RESET;
299
+ if (ch === "(" || ch === ")" || ch === "<" || ch === ">")
300
+ return C.yellow + ch + RESET;
301
+ if (ch === "_" || ch === "-") return C.yellow + ch + RESET;
302
+ if (ch === "." || ch === "*") return C.cyan + ch + RESET;
303
+ if (ch === "z") return C.gray + ch + RESET;
304
+ if (ch === "!") return C.brightYellow + ch + RESET;
305
+ if (ch === "~") return C.cyan + ch + RESET;
306
+ if (ch === " ") return ch;
307
+ return C.yellow + ch + RESET;
308
+ },
309
+
310
+ cat: (ch) => {
311
+ if (ch === "o" || ch === "O" || ch === "^") return C.brightGreen + ch + RESET;
312
+ if (ch === "!" || ch === "~") return C.brightMagenta + ch + RESET;
313
+ if (ch === "z") return C.gray + ch + RESET;
314
+ if (ch === ".") return C.gray + ch + RESET;
315
+ if (ch === " ") return ch;
316
+ return C.green + ch + RESET;
317
+ },
318
+
319
+ dragon: (ch) => {
320
+ if (ch === "o" || ch === "O" || ch === "^") return C.brightRed + ch + RESET;
321
+ if (ch === "~") return C.red + ch + RESET;
322
+ if (ch === "!" || ch === "o" || ch === "O") return C.brightYellow + ch + RESET;
323
+ if (ch === "z") return C.gray + ch + RESET;
324
+ if (ch === " ") return ch;
325
+ return C.green + ch + RESET;
326
+ },
327
+
328
+ axolotl: (ch) => {
329
+ if (ch === "o" || ch === "O") return C.brightMagenta + ch + RESET;
330
+ if (ch === "^" || ch === "_") return C.magenta + ch + RESET;
331
+ if (ch === "!" || ch === "o") return C.brightYellow + ch + RESET;
332
+ if (ch === "z") return C.gray + ch + RESET;
333
+ if (ch === ".") return C.cyan + ch + RESET;
334
+ if (ch === " ") return ch;
335
+ return C.magenta + ch + RESET;
336
+ },
337
+
338
+ robot: (ch) => {
339
+ if (ch === "O" || ch === "#") return C.brightCyan + ch + RESET;
340
+ if (ch === "o") return C.cyan + ch + RESET;
341
+ if (ch === ".") return C.gray + ch + RESET;
342
+ if (ch === "!" || ch === "o") return C.brightYellow + ch + RESET;
343
+ if (ch === "z") return C.gray + ch + RESET;
344
+ if (ch === " ") return ch;
345
+ return C.gray + ch + RESET;
346
+ },
347
+
348
+ ghost: (ch) => {
349
+ if (ch === "o" || ch === "O") return C.brightCyan + ch + RESET;
350
+ if (ch === "~") return C.cyan + ch + RESET;
351
+ if (ch === "!" || ch === "o") return C.brightMagenta + ch + RESET;
352
+ if (ch === "z") return C.gray + ch + RESET;
353
+ if (ch === ".") return C.gray + ch + RESET;
354
+ if (ch === " ") return ch;
355
+ return C.brightCyan + ch + RESET;
356
+ },
357
+ };
358
+
359
+ export const SPECIES = ["duck", "cat", "dragon", "axolotl", "robot", "ghost"];
360
+
361
+ export const STATES = ["idle", "working", "celebrating", "scared", "sleeping"];
362
+
363
+ // Random flavor line shown under the buddy in idle / sleeping.
364
+ export const FLAVOR = {
365
+ duck: ["quack.", "need code.", "swimming in semicolons"],
366
+ cat: ["purr.", "knock something off desk?", "yarn > threads"],
367
+ dragon: ["rawr.", "hoarding stack traces", "breathing fire on bugs"],
368
+ axolotl: ["ambien?", "regenerating tail...", "neotenic forever"],
369
+ robot: ["beep boop", "01010110", "needs oil"],
370
+ ghost: ["boo.", "haunting the stack", "passed on... to prod"],
371
+ };
372
+
373
+ function targetWidth() {
374
+ // All species share this width so the rendered canvas never resizes when
375
+ // a state changes. Each row is padded (or trimmed) to this length.
376
+ return 20;
377
+ }
378
+
379
+ export function paint(species, state) {
380
+ const frame = RAW[species][state];
381
+ const palette = PALETTES[species];
382
+ const w = targetWidth();
383
+ return frame.map((row) => {
384
+ let out = "";
385
+ // Use [...row] to count grapheme-ish units. ASCII art is pure ASCII
386
+ // so spread over code units is fine and matches visual columns.
387
+ for (const ch of row) out += palette(ch);
388
+ if (out.length < w) out += " ".repeat(w - out.length);
389
+ return out;
390
+ });
391
+ }
392
+
393
+ export function frameHeight(species) {
394
+ return RAW[species].idle.length;
395
+ }
396
+
397
+ export function frameWidth() {
398
+ return targetWidth();
399
+ }
@@ -0,0 +1,161 @@
1
+ // Buddy state machine + attribute bookkeeping.
2
+ //
3
+ // Attributes (all 0..100):
4
+ // hunger 100 = full, 0 = starving
5
+ // happiness 100 = delighted, 0 = grumpy
6
+ // energy 100 = wide awake, 0 = exhausted
7
+ //
8
+ // External events from opencode (via tmux pane polling or CLI notify):
9
+ // working -> state becomes 'working' (boosts happiness over time)
10
+ // done -> state becomes 'celebrating' for a few seconds
11
+ // error -> state becomes 'scared' for a few seconds
12
+ // idle -> state returns to 'idle' / 'sleeping' based on energy
13
+ //
14
+ // User actions:
15
+ // feed -> +25 hunger, -5 energy
16
+ // play -> +20 happiness, -10 energy, -5 hunger
17
+ // rest -> +30 energy
18
+ //
19
+ // Time decay: every minute, hunger -0.5, happiness -0.3, energy -0.2.
20
+
21
+ export const DEFAULT_STATE = {
22
+ species: "duck",
23
+ name: "Quack",
24
+ hunger: 80,
25
+ happiness: 80,
26
+ energy: 100,
27
+ xp: 0,
28
+ level: 1,
29
+ hatchedAt: Date.now(),
30
+ lastFed: Date.now(),
31
+ lastPlayed: Date.now(),
32
+ lastTick: Date.now(),
33
+ // ephemeral, not persisted as state-of-truth
34
+ state: "idle",
35
+ stateUntil: 0,
36
+ stateReason: "",
37
+ };
38
+
39
+ const CLAMP = (n, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n));
40
+
41
+ export function tick(state, now = Date.now()) {
42
+ const minutes = (now - state.lastTick) / 60_000;
43
+ if (minutes <= 0) return state;
44
+
45
+ const next = {
46
+ ...state,
47
+ hunger: CLAMP(state.hunger - 0.5 * minutes),
48
+ happiness: CLAMP(state.happiness - 0.3 * minutes),
49
+ energy: CLAMP(state.energy - 0.2 * minutes),
50
+ lastTick: now,
51
+ };
52
+ return next;
53
+ }
54
+
55
+ export function feed(state) {
56
+ return {
57
+ ...state,
58
+ hunger: CLAMP(state.hunger + 25),
59
+ energy: CLAMP(state.energy - 5),
60
+ lastFed: Date.now(),
61
+ };
62
+ }
63
+
64
+ export function play(state) {
65
+ return {
66
+ ...state,
67
+ happiness: CLAMP(state.happiness + 20),
68
+ energy: CLAMP(state.energy - 10),
69
+ hunger: CLAMP(state.hunger - 5),
70
+ lastPlayed: Date.now(),
71
+ xp: state.xp + 5,
72
+ };
73
+ }
74
+
75
+ export function rest(state) {
76
+ return {
77
+ ...state,
78
+ energy: CLAMP(state.energy + 30),
79
+ };
80
+ }
81
+
82
+ export function rename(state, name) {
83
+ return { ...state, name: String(name).slice(0, 20) };
84
+ }
85
+
86
+ export function switchSpecies(state, species) {
87
+ return { ...state, species };
88
+ }
89
+
90
+ export function celebrate(state, durationMs = 4000) {
91
+ return {
92
+ ...state,
93
+ happiness: CLAMP(state.happiness + 5),
94
+ state: "celebrating",
95
+ stateUntil: Date.now() + durationMs,
96
+ stateReason: "session idle",
97
+ };
98
+ }
99
+
100
+ export function scared(state, durationMs = 5000) {
101
+ return {
102
+ ...state,
103
+ state: "scared",
104
+ stateUntil: Date.now() + durationMs,
105
+ stateReason: "session error",
106
+ };
107
+ }
108
+
109
+ export function working(state) {
110
+ // Don't overwrite a stronger transient state
111
+ if (state.state === "celebrating" && Date.now() < state.stateUntil)
112
+ return state;
113
+ if (state.state === "scared" && Date.now() < state.stateUntil) return state;
114
+ return { ...state, state: "working", stateUntil: 0, stateReason: "" };
115
+ }
116
+
117
+ // Derive state purely from attributes when no external override is active.
118
+ export function deriveState(state, now = Date.now()) {
119
+ if (state.state === "celebrating" && now < state.stateUntil) return state;
120
+ if (state.state === "scared" && now < state.stateUntil) return state;
121
+
122
+ if (state.energy < 20) {
123
+ return { ...state, state: "sleeping", stateReason: "low energy" };
124
+ }
125
+ if (state.hunger < 25) {
126
+ return { ...state, state: "scared", stateReason: "hungry", stateUntil: now + 30_000 };
127
+ }
128
+ if (state.state === "working") return state;
129
+ return { ...state, state: "idle", stateReason: "" };
130
+ }
131
+
132
+ export function maybeLevelUp(state) {
133
+ const xpNeeded = state.level * 50;
134
+ if (state.xp < xpNeeded) return state;
135
+ return {
136
+ ...state,
137
+ level: state.level + 1,
138
+ xp: state.xp - xpNeeded,
139
+ happiness: CLAMP(state.happiness + 10),
140
+ };
141
+ }
142
+
143
+ export function describe(state) {
144
+ const mood =
145
+ state.happiness > 80
146
+ ? "happy"
147
+ : state.happiness > 50
148
+ ? "okay"
149
+ : state.happiness > 25
150
+ ? "grumpy"
151
+ : "depressed";
152
+ const tummy =
153
+ state.hunger > 70
154
+ ? "full"
155
+ : state.hunger > 40
156
+ ? "peckish"
157
+ : state.hunger > 20
158
+ ? "hungry"
159
+ : "starving";
160
+ return `${state.name} the ${state.species} | Lv ${state.level} | ${mood} | ${tummy} | hunger ${Math.floor(state.hunger)} happiness ${Math.floor(state.happiness)} energy ${Math.floor(state.energy)} | xp ${state.xp}/${state.level * 50}`;
161
+ }