opencode-buddy 0.2.1 → 0.3.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.
@@ -1,168 +0,0 @@
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
- lastTick: Date.now(),
62
- };
63
- }
64
-
65
- export function play(state) {
66
- return {
67
- ...state,
68
- happiness: CLAMP(state.happiness + 20),
69
- energy: CLAMP(state.energy - 10),
70
- hunger: CLAMP(state.hunger - 5),
71
- lastPlayed: Date.now(),
72
- lastTick: Date.now(),
73
- xp: state.xp + 5,
74
- };
75
- }
76
-
77
- export function rest(state) {
78
- return {
79
- ...state,
80
- energy: CLAMP(state.energy + 30),
81
- lastTick: Date.now(),
82
- };
83
- }
84
-
85
- export function rename(state, name) {
86
- return {
87
- ...state,
88
- name: String(name).slice(0, 20),
89
- lastTick: Date.now(),
90
- };
91
- }
92
-
93
- export function switchSpecies(state, species) {
94
- return { ...state, species, lastTick: Date.now() };
95
- }
96
-
97
- export function celebrate(state, durationMs = 4000) {
98
- return {
99
- ...state,
100
- happiness: CLAMP(state.happiness + 5),
101
- state: "celebrating",
102
- stateUntil: Date.now() + durationMs,
103
- stateReason: "session idle",
104
- };
105
- }
106
-
107
- export function scared(state, durationMs = 5000) {
108
- return {
109
- ...state,
110
- state: "scared",
111
- stateUntil: Date.now() + durationMs,
112
- stateReason: "session error",
113
- };
114
- }
115
-
116
- export function working(state) {
117
- // Don't overwrite a stronger transient state
118
- if (state.state === "celebrating" && Date.now() < state.stateUntil)
119
- return state;
120
- if (state.state === "scared" && Date.now() < state.stateUntil) return state;
121
- return { ...state, state: "working", stateUntil: 0, stateReason: "" };
122
- }
123
-
124
- // Derive state purely from attributes when no external override is active.
125
- export function deriveState(state, now = Date.now()) {
126
- if (state.state === "celebrating" && now < state.stateUntil) return state;
127
- if (state.state === "scared" && now < state.stateUntil) return state;
128
-
129
- if (state.energy < 20) {
130
- return { ...state, state: "sleeping", stateReason: "low energy" };
131
- }
132
- if (state.hunger < 25) {
133
- return { ...state, state: "scared", stateReason: "hungry", stateUntil: now + 30_000 };
134
- }
135
- if (state.state === "working") return state;
136
- return { ...state, state: "idle", stateReason: "" };
137
- }
138
-
139
- export function maybeLevelUp(state) {
140
- const xpNeeded = state.level * 50;
141
- if (state.xp < xpNeeded) return state;
142
- return {
143
- ...state,
144
- level: state.level + 1,
145
- xp: state.xp - xpNeeded,
146
- happiness: CLAMP(state.happiness + 10),
147
- };
148
- }
149
-
150
- export function describe(state) {
151
- const mood =
152
- state.happiness > 80
153
- ? "happy"
154
- : state.happiness > 50
155
- ? "okay"
156
- : state.happiness > 25
157
- ? "grumpy"
158
- : "depressed";
159
- const tummy =
160
- state.hunger > 70
161
- ? "full"
162
- : state.hunger > 40
163
- ? "peckish"
164
- : state.hunger > 20
165
- ? "hungry"
166
- : "starving";
167
- 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}`;
168
- }
package/src/index.js DELETED
@@ -1,332 +0,0 @@
1
- // Main runtime. Wires together state, persistence, TUI, tmux polling.
2
- //
3
- // Usage from bin/opencode-buddy.js:
4
- // await runInteractive() // for `opencode-buddy start`
5
- // await runCommand(args) // for all other subcommands
6
-
7
- import * as persistence from "./persistence.js";
8
- import { createTui } from "./tui.js";
9
- import {
10
- tick,
11
- feed,
12
- play,
13
- rest,
14
- rename,
15
- switchSpecies,
16
- celebrate,
17
- scared,
18
- working,
19
- deriveState,
20
- maybeLevelUp,
21
- describe as describeState,
22
- DEFAULT_STATE,
23
- } from "./buddy/state.js";
24
- import { SPECIES } from "./buddy/species.js";
25
- import {
26
- inTmux,
27
- currentPaneId,
28
- splitRight,
29
- captureMainPane,
30
- inferActivity,
31
- killBuddyPane,
32
- } from "./tmux.js";
33
-
34
- const TICK_MS = 1000;
35
- const SAVE_EVERY_MS = 5000;
36
-
37
- const HINT = `keys: f feed · p play · r rename · s switch · z rest · n hatch · q quit`;
38
-
39
- export async function loadOrInit() {
40
- const existing = await persistence.load();
41
- if (existing) return existing;
42
- const fresh = { ...DEFAULT_STATE, lastTick: Date.now() };
43
- await persistence.save(fresh);
44
- return fresh;
45
- }
46
-
47
- export async function runInteractive({ autoSplit = true } = {}) {
48
- if (!inTmux()) {
49
- throw new Error(
50
- "opencode-buddy start must run inside a tmux session. " +
51
- "Start tmux first, then run: opencode-buddy start",
52
- );
53
- }
54
-
55
- let state = await loadOrInit();
56
- let buddyPane = null;
57
- if (autoSplit) {
58
- // The `start` subcommand: split a side pane that runs `render`,
59
- // then exit so this main-pane process goes back to the shell prompt.
60
- // The render process owns the TUI; we just kick it off.
61
- splitRight(
62
- `cd ${process.cwd()} && exec ${process.execPath} ${process.argv[1]} render`,
63
- 28,
64
- );
65
- return;
66
- }
67
-
68
- const tui = createTui({
69
- onKey: (key) => {
70
- switch (key) {
71
- case "f":
72
- state = feed(state);
73
- flash("yum!");
74
- break;
75
- case "p":
76
- state = play(state);
77
- flash("hehe!");
78
- break;
79
- case "z":
80
- state = rest(state);
81
- flash("zzz...");
82
- break;
83
- case "n":
84
- state = { ...DEFAULT_STATE, lastTick: Date.now() };
85
- flash("new buddy!");
86
- break;
87
- case "r": {
88
- // simple prompt via stdout
89
- process.stdout.write(`\x1b[2J\x1b[Hnew name: `);
90
- let name = "";
91
- const onData = (b) => {
92
- const s = b.toString("utf8");
93
- if (s === "\n" || s === "\r" || s === "\u0003") {
94
- process.stdin.off("data", onData);
95
- if (name) state = rename(state, name);
96
- flash(name ? `hi ${name}!` : "ok");
97
- } else if (s === "\u007f" || s === "\b") {
98
- name = name.slice(0, -1);
99
- } else {
100
- name += s.replace(/[\r\n]/g, "");
101
- }
102
- };
103
- process.stdin.on("data", onData);
104
- break;
105
- }
106
- case "s": {
107
- // cycle species
108
- const idx = SPECIES.indexOf(state.species);
109
- const next = SPECIES[(idx + 1) % SPECIES.length];
110
- state = switchSpecies(state, next);
111
- flash(`now ${next}`);
112
- break;
113
- }
114
- case "q":
115
- case "ctrl-c":
116
- cleanup();
117
- process.exit(0);
118
- break;
119
- default:
120
- // ignore
121
- break;
122
- }
123
- },
124
- });
125
-
126
- let flashUntil = 0;
127
- let flashText = "";
128
- function flash(text, ms = 1500) {
129
- flashText = text;
130
- flashUntil = Date.now() + ms;
131
- }
132
-
133
- function buildHint() {
134
- if (Date.now() < flashUntil) return `${flashText} · ${HINT}`;
135
- return HINT;
136
- }
137
-
138
- let lastActivity = "unknown";
139
- let lastPoll = 0;
140
- let lastSave = 0;
141
- let lastReload = 0;
142
- let lastKnownMtime = 0;
143
- let running = true;
144
-
145
- async function loop() {
146
- while (running) {
147
- const now = Date.now();
148
-
149
- // 0. Reload from disk every 1.5s so the side pane picks up
150
- // changes made by other processes (opencode plugin, headless
151
- // CLI invocations from the main pane, etc.). Use mtime to
152
- // detect external writes — lastTick is not a reliable
153
- // signal because tick() rewrites it every loop.
154
- if (now - lastReload > 1500) {
155
- lastReload = now;
156
- const mt = await persistence.mtime();
157
- if (mt > 0 && mt !== lastKnownMtime) {
158
- lastKnownMtime = mt;
159
- const onDisk = await persistence.load();
160
- if (onDisk) {
161
- // Preserve in-memory transient state overrides
162
- const transient = {
163
- state: state.state,
164
- stateUntil: state.stateUntil,
165
- stateReason: state.stateReason,
166
- };
167
- state = { ...onDisk, ...transient };
168
- }
169
- }
170
- }
171
-
172
- // 1. tick attributes for time decay
173
- state = tick(state, now);
174
-
175
- // 2. capture tmux main pane every 2s for activity inference
176
- if (now - lastPoll > 2000) {
177
- lastPoll = now;
178
- const text = captureMainPane(60);
179
- const activity = inferActivity(text);
180
- if (activity !== lastActivity) {
181
- lastActivity = activity;
182
- if (activity === "working") state = working(state);
183
- else if (activity === "error") state = scared(state);
184
- else if (activity === "idle")
185
- state = celebrate(state, 1500);
186
- }
187
- }
188
-
189
- // 3. derive final state (transient overrides first, then attributes)
190
- state = deriveState(state, now);
191
-
192
- // 4. level up check
193
- state = maybeLevelUp(state);
194
-
195
- // 5. render
196
- tui.render(state, buildHint());
197
-
198
- // 6. persist periodically
199
- if (now - lastSave > SAVE_EVERY_MS) {
200
- lastSave = now;
201
- await persistence.save(state).catch(() => {});
202
- lastKnownMtime = await persistence.mtime();
203
- }
204
-
205
- await new Promise((r) => setTimeout(r, TICK_MS));
206
- }
207
- }
208
-
209
- function cleanup() {
210
- running = false;
211
- tui.stop();
212
- if (buddyPane) killBuddyPane(buddyPane);
213
- }
214
-
215
- process.on("SIGINT", () => {
216
- cleanup();
217
- process.exit(0);
218
- });
219
- process.on("SIGTERM", () => {
220
- cleanup();
221
- process.exit(0);
222
- });
223
-
224
- tui.start();
225
- await loop();
226
- }
227
-
228
- // Headless command runner: feed, play, rename, switch, hatch, stats, notify
229
- export async function runCommand(argv) {
230
- const [cmd, ...rest] = argv;
231
- let state = await loadOrInit();
232
-
233
- switch (cmd) {
234
- case "hatch":
235
- case "reset": {
236
- const species = rest[0] && SPECIES.includes(rest[0]) ? rest[0] : "duck";
237
- state = {
238
- ...DEFAULT_STATE,
239
- species,
240
- name: rest[1] || DEFAULT_STATE.name,
241
- lastTick: Date.now(),
242
- };
243
- await persistence.save(state);
244
- console.log(`Hatched a new ${state.species} named ${state.name}.`);
245
- break;
246
- }
247
- case "feed":
248
- state = feed(state);
249
- await persistence.save(state);
250
- console.log(`Fed ${state.name}. hunger -> ${Math.floor(state.hunger)}`);
251
- break;
252
- case "play":
253
- state = play(state);
254
- await persistence.save(state);
255
- console.log(
256
- `Played with ${state.name}. happiness -> ${Math.floor(state.happiness)}`,
257
- );
258
- break;
259
- case "rest":
260
- state = rest(state);
261
- await persistence.save(state);
262
- console.log(`${state.name} took a nap.`);
263
- break;
264
- case "rename": {
265
- const name = rest.join(" ").trim();
266
- if (!name) {
267
- console.error("usage: opencode-buddy rename <name>");
268
- process.exit(2);
269
- }
270
- state = rename(state, name);
271
- await persistence.save(state);
272
- console.log(`Renamed to ${state.name}.`);
273
- break;
274
- }
275
- case "switch": {
276
- const species = rest[0];
277
- if (!species || !SPECIES.includes(species)) {
278
- console.error(`species must be one of: ${SPECIES.join(", ")}`);
279
- process.exit(2);
280
- }
281
- state = switchSpecies(state, species);
282
- await persistence.save(state);
283
- console.log(`${state.name} transformed into a ${species}.`);
284
- break;
285
- }
286
- case "stats":
287
- console.log(describeState(state));
288
- break;
289
- case "notify": {
290
- const event = rest[0];
291
- if (event === "working" || event === "start")
292
- state = working(state);
293
- else if (event === "done" || event === "celebrate")
294
- state = celebrate(state);
295
- else if (event === "error" || event === "scared")
296
- state = scared(state);
297
- else {
298
- console.error(
299
- "usage: opencode-buddy notify <working|done|error>",
300
- );
301
- process.exit(2);
302
- }
303
- await persistence.save(state);
304
- console.log(`Notified buddy: ${event}`);
305
- break;
306
- }
307
- case "path":
308
- console.log(persistence.pathForDisplay());
309
- break;
310
- default:
311
- printUsage();
312
- process.exit(cmd ? 2 : 0);
313
- }
314
- }
315
-
316
- function printUsage() {
317
- console.log(`opencode-buddy — a virtual pet for your tmux session
318
-
319
- Usage:
320
- opencode-buddy start Launch interactive buddy in a tmux side pane
321
- opencode-buddy hatch [species] [name] Start a brand new buddy (resets state)
322
- opencode-buddy feed Feed your buddy
323
- opencode-buddy play Play with your buddy
324
- opencode-buddy rest Let your buddy rest
325
- opencode-buddy rename <name> Give your buddy a new name
326
- opencode-buddy switch <species> Change species (${SPECIES.join(", ")})
327
- opencode-buddy notify <event> Notify buddy of an opencode event
328
- (working | done | error)
329
- opencode-buddy stats Print current buddy stats
330
- opencode-buddy path Print path to state file
331
- `);
332
- }
package/src/install.js DELETED
@@ -1,157 +0,0 @@
1
- // One-shot installer. Writes the /buddy slash command markdown file
2
- // and adds this package to opencode.json's plugin list so opencode will
3
- // bun-install it on the next startup.
4
- //
5
- // Re-runnable: safely no-ops if everything is already in place.
6
-
7
- import { promises as fs } from "node:fs";
8
- import path from "node:path";
9
- import os from "node:os";
10
-
11
- const OPENCODE_DIR = path.join(os.homedir(), ".config", "opencode");
12
- const COMMANDS_DIR = path.join(OPENCODE_DIR, "commands");
13
- const COMMAND_FILE = path.join(COMMANDS_DIR, "buddy.md");
14
- const CONFIG_FILE = path.join(OPENCODE_DIR, "opencode.json");
15
-
16
- const PLUGIN_NAME = "opencode-buddy";
17
-
18
- const COMMAND_BODY = `---
19
- description: Interact with your virtual buddy companion.
20
- ---
21
-
22
- The user typed \`/buddy\`. Use the \`buddy\` tool to respond.
23
-
24
- If the user typed \`/buddy\` with no arguments, call the tool with
25
- \`action: "status"\`. If they passed sub-arguments like \`/buddy feed\`
26
- or \`/buddy switch dragon\`, map them to the tool's \`action\`,
27
- \`species\`, and \`name\` arguments accordingly. Examples:
28
-
29
- /buddy -> buddy({ action: "status" })
30
- /buddy feed -> buddy({ action: "feed" })
31
- /buddy play -> buddy({ action: "play" })
32
- /buddy switch cat -> buddy({ action: "switch", species: "cat" })
33
- /buddy rename Mia -> buddy({ action: "rename", name: "Mia" })
34
- /buddy hatch dragon Sparky -> buddy({ action: "hatch", species: "dragon", name: "Sparky" })
35
- /buddy ascii -> buddy({ action: "ascii" })
36
-
37
- Always show the tool's returned text to the user. If the user just wants
38
- to see the buddy, call \`action: "ascii"\` to render the current art in a
39
- code block.
40
- `;
41
-
42
- export async function install() {
43
- const steps = [];
44
- const errors = [];
45
-
46
- // 1. Write the slash command file
47
- try {
48
- await fs.mkdir(COMMANDS_DIR, { recursive: true });
49
- const existing = await fs.readFile(COMMAND_FILE, "utf8").catch(() => null);
50
- if (existing === COMMAND_BODY) {
51
- steps.push(`= ${COMMAND_FILE} (unchanged)`);
52
- } else {
53
- await fs.writeFile(COMMAND_FILE, COMMAND_BODY);
54
- steps.push(`+ ${COMMAND_FILE}`);
55
- }
56
- } catch (err) {
57
- errors.push(`failed to write ${COMMAND_FILE}: ${err.message}`);
58
- }
59
-
60
- // 2. Patch opencode.json
61
- try {
62
- let config = {};
63
- try {
64
- const raw = await fs.readFile(CONFIG_FILE, "utf8");
65
- config = JSON.parse(raw);
66
- } catch (err) {
67
- if (err.code !== "ENOENT") throw err;
68
- }
69
-
70
- let pluginList = config.plugin;
71
- if (pluginList == null) {
72
- pluginList = [];
73
- config.plugin = pluginList;
74
- } else if (typeof pluginList === "string") {
75
- pluginList = [pluginList];
76
- config.plugin = pluginList;
77
- } else if (!Array.isArray(pluginList)) {
78
- errors.push(
79
- `${CONFIG_FILE} has an unexpected "plugin" field type; expected array.`,
80
- );
81
- pluginList = [];
82
- }
83
-
84
- if (!pluginList.includes(PLUGIN_NAME)) {
85
- pluginList.push(PLUGIN_NAME);
86
- config.plugin = pluginList;
87
- const tmp = CONFIG_FILE + ".tmp";
88
- await fs.writeFile(tmp, JSON.stringify(config, null, 2));
89
- await fs.rename(tmp, CONFIG_FILE);
90
- steps.push(`~ ${CONFIG_FILE} (added "${PLUGIN_NAME}")`);
91
- } else {
92
- steps.push(`= ${CONFIG_FILE} (already lists "${PLUGIN_NAME}")`);
93
- }
94
- } catch (err) {
95
- errors.push(`failed to patch ${CONFIG_FILE}: ${err.message}`);
96
- }
97
-
98
- // 3. Report
99
- console.log("opencode-buddy install");
100
- console.log("------------------------");
101
- for (const s of steps) console.log(` ${s}`);
102
- if (errors.length) {
103
- console.log("\nErrors:");
104
- for (const e of errors) console.log(` ! ${e}`);
105
- process.exit(1);
106
- }
107
- console.log("\nDone. Restart opencode to activate the /buddy command.");
108
- console.log("Then in opencode, try: /buddy");
109
- }
110
-
111
- export async function uninstall() {
112
- const steps = [];
113
- const errors = [];
114
-
115
- // 1. Remove the command file
116
- try {
117
- await fs.unlink(COMMAND_FILE);
118
- steps.push(`- ${COMMAND_FILE}`);
119
- } catch (err) {
120
- if (err.code === "ENOENT") steps.push(`= ${COMMAND_FILE} (not present)`);
121
- else errors.push(`failed to remove ${COMMAND_FILE}: ${err.message}`);
122
- }
123
-
124
- // 2. Remove from opencode.json
125
- try {
126
- const raw = await fs.readFile(CONFIG_FILE, "utf8");
127
- const config = JSON.parse(raw);
128
- if (Array.isArray(config.plugin)) {
129
- const before = config.plugin.length;
130
- config.plugin = config.plugin.filter((p) => p !== PLUGIN_NAME);
131
- if (config.plugin.length === 0) delete config.plugin;
132
- if (config.plugin.length !== before) {
133
- const tmp = CONFIG_FILE + ".tmp";
134
- await fs.writeFile(tmp, JSON.stringify(config, null, 2));
135
- await fs.rename(tmp, CONFIG_FILE);
136
- steps.push(`~ ${CONFIG_FILE} (removed "${PLUGIN_NAME}")`);
137
- } else {
138
- steps.push(`= ${CONFIG_FILE} (no entry to remove)`);
139
- }
140
- } else {
141
- steps.push(`= ${CONFIG_FILE} (no plugin array)`);
142
- }
143
- } catch (err) {
144
- if (err.code === "ENOENT") steps.push(`= ${CONFIG_FILE} (not present)`);
145
- else errors.push(`failed to patch ${CONFIG_FILE}: ${err.message}`);
146
- }
147
-
148
- console.log("opencode-buddy uninstall");
149
- console.log("--------------------------");
150
- for (const s of steps) console.log(` ${s}`);
151
- if (errors.length) {
152
- console.log("\nErrors:");
153
- for (const e of errors) console.log(` ! ${e}`);
154
- process.exit(1);
155
- }
156
- console.log("\nDone. Restart opencode to deactivate the /buddy command.");
157
- }