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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/opencode-buddy.js +45 -0
- package/package.json +61 -0
- package/src/buddy/species.js +399 -0
- package/src/buddy/state.js +161 -0
- package/src/index.js +302 -0
- package/src/install.js +157 -0
- package/src/persistence.js +28 -0
- package/src/plugin.js +178 -0
- package/src/tmux.js +90 -0
- package/src/tui.js +164 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
buddyPane = splitRight(
|
|
59
|
+
`cd ${process.cwd()} && exec ${process.execPath} ${process.argv[1]} render`,
|
|
60
|
+
28,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tui = createTui({
|
|
65
|
+
onKey: (key) => {
|
|
66
|
+
switch (key) {
|
|
67
|
+
case "f":
|
|
68
|
+
state = feed(state);
|
|
69
|
+
flash("yum!");
|
|
70
|
+
break;
|
|
71
|
+
case "p":
|
|
72
|
+
state = play(state);
|
|
73
|
+
flash("hehe!");
|
|
74
|
+
break;
|
|
75
|
+
case "z":
|
|
76
|
+
state = rest(state);
|
|
77
|
+
flash("zzz...");
|
|
78
|
+
break;
|
|
79
|
+
case "n":
|
|
80
|
+
state = { ...DEFAULT_STATE, lastTick: Date.now() };
|
|
81
|
+
flash("new buddy!");
|
|
82
|
+
break;
|
|
83
|
+
case "r": {
|
|
84
|
+
// simple prompt via stdout
|
|
85
|
+
process.stdout.write(`\x1b[2J\x1b[Hnew name: `);
|
|
86
|
+
let name = "";
|
|
87
|
+
const onData = (b) => {
|
|
88
|
+
const s = b.toString("utf8");
|
|
89
|
+
if (s === "\n" || s === "\r" || s === "\u0003") {
|
|
90
|
+
process.stdin.off("data", onData);
|
|
91
|
+
if (name) state = rename(state, name);
|
|
92
|
+
flash(name ? `hi ${name}!` : "ok");
|
|
93
|
+
} else if (s === "\u007f" || s === "\b") {
|
|
94
|
+
name = name.slice(0, -1);
|
|
95
|
+
} else {
|
|
96
|
+
name += s.replace(/[\r\n]/g, "");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
process.stdin.on("data", onData);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "s": {
|
|
103
|
+
// cycle species
|
|
104
|
+
const idx = SPECIES.indexOf(state.species);
|
|
105
|
+
const next = SPECIES[(idx + 1) % SPECIES.length];
|
|
106
|
+
state = switchSpecies(state, next);
|
|
107
|
+
flash(`now ${next}`);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "q":
|
|
111
|
+
case "ctrl-c":
|
|
112
|
+
cleanup();
|
|
113
|
+
process.exit(0);
|
|
114
|
+
break;
|
|
115
|
+
default:
|
|
116
|
+
// ignore
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
let flashUntil = 0;
|
|
123
|
+
let flashText = "";
|
|
124
|
+
function flash(text, ms = 1500) {
|
|
125
|
+
flashText = text;
|
|
126
|
+
flashUntil = Date.now() + ms;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildHint() {
|
|
130
|
+
if (Date.now() < flashUntil) return `${flashText} · ${HINT}`;
|
|
131
|
+
return HINT;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let lastActivity = "unknown";
|
|
135
|
+
let lastPoll = 0;
|
|
136
|
+
let lastSave = 0;
|
|
137
|
+
let running = true;
|
|
138
|
+
|
|
139
|
+
async function loop() {
|
|
140
|
+
while (running) {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
|
|
143
|
+
// 1. tick attributes for time decay
|
|
144
|
+
state = tick(state, now);
|
|
145
|
+
|
|
146
|
+
// 2. capture tmux main pane every 2s for activity inference
|
|
147
|
+
if (now - lastPoll > 2000) {
|
|
148
|
+
lastPoll = now;
|
|
149
|
+
const text = captureMainPane(60);
|
|
150
|
+
const activity = inferActivity(text);
|
|
151
|
+
if (activity !== lastActivity) {
|
|
152
|
+
lastActivity = activity;
|
|
153
|
+
if (activity === "working") state = working(state);
|
|
154
|
+
else if (activity === "error") state = scared(state);
|
|
155
|
+
else if (activity === "idle")
|
|
156
|
+
state = celebrate(state, 1500);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 3. derive final state (transient overrides first, then attributes)
|
|
161
|
+
state = deriveState(state, now);
|
|
162
|
+
|
|
163
|
+
// 4. level up check
|
|
164
|
+
state = maybeLevelUp(state);
|
|
165
|
+
|
|
166
|
+
// 5. render
|
|
167
|
+
tui.render(state, buildHint());
|
|
168
|
+
|
|
169
|
+
// 6. persist periodically
|
|
170
|
+
if (now - lastSave > SAVE_EVERY_MS) {
|
|
171
|
+
lastSave = now;
|
|
172
|
+
await persistence.save(state).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await new Promise((r) => setTimeout(r, TICK_MS));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function cleanup() {
|
|
180
|
+
running = false;
|
|
181
|
+
tui.stop();
|
|
182
|
+
if (buddyPane) killBuddyPane(buddyPane);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
process.on("SIGINT", () => {
|
|
186
|
+
cleanup();
|
|
187
|
+
process.exit(0);
|
|
188
|
+
});
|
|
189
|
+
process.on("SIGTERM", () => {
|
|
190
|
+
cleanup();
|
|
191
|
+
process.exit(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
tui.start();
|
|
195
|
+
await loop();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Headless command runner: feed, play, rename, switch, hatch, stats, notify
|
|
199
|
+
export async function runCommand(argv) {
|
|
200
|
+
const [cmd, ...rest] = argv;
|
|
201
|
+
let state = await loadOrInit();
|
|
202
|
+
|
|
203
|
+
switch (cmd) {
|
|
204
|
+
case "hatch":
|
|
205
|
+
case "reset": {
|
|
206
|
+
const species = rest[0] && SPECIES.includes(rest[0]) ? rest[0] : "duck";
|
|
207
|
+
state = {
|
|
208
|
+
...DEFAULT_STATE,
|
|
209
|
+
species,
|
|
210
|
+
name: rest[1] || DEFAULT_STATE.name,
|
|
211
|
+
lastTick: Date.now(),
|
|
212
|
+
};
|
|
213
|
+
await persistence.save(state);
|
|
214
|
+
console.log(`Hatched a new ${state.species} named ${state.name}.`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "feed":
|
|
218
|
+
state = feed(state);
|
|
219
|
+
await persistence.save(state);
|
|
220
|
+
console.log(`Fed ${state.name}. hunger -> ${Math.floor(state.hunger)}`);
|
|
221
|
+
break;
|
|
222
|
+
case "play":
|
|
223
|
+
state = play(state);
|
|
224
|
+
await persistence.save(state);
|
|
225
|
+
console.log(
|
|
226
|
+
`Played with ${state.name}. happiness -> ${Math.floor(state.happiness)}`,
|
|
227
|
+
);
|
|
228
|
+
break;
|
|
229
|
+
case "rest":
|
|
230
|
+
state = rest(state);
|
|
231
|
+
await persistence.save(state);
|
|
232
|
+
console.log(`${state.name} took a nap.`);
|
|
233
|
+
break;
|
|
234
|
+
case "rename": {
|
|
235
|
+
const name = rest.join(" ").trim();
|
|
236
|
+
if (!name) {
|
|
237
|
+
console.error("usage: opencode-buddy rename <name>");
|
|
238
|
+
process.exit(2);
|
|
239
|
+
}
|
|
240
|
+
state = rename(state, name);
|
|
241
|
+
await persistence.save(state);
|
|
242
|
+
console.log(`Renamed to ${state.name}.`);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "switch": {
|
|
246
|
+
const species = rest[0];
|
|
247
|
+
if (!species || !SPECIES.includes(species)) {
|
|
248
|
+
console.error(`species must be one of: ${SPECIES.join(", ")}`);
|
|
249
|
+
process.exit(2);
|
|
250
|
+
}
|
|
251
|
+
state = switchSpecies(state, species);
|
|
252
|
+
await persistence.save(state);
|
|
253
|
+
console.log(`${state.name} transformed into a ${species}.`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "stats":
|
|
257
|
+
console.log(describeState(state));
|
|
258
|
+
break;
|
|
259
|
+
case "notify": {
|
|
260
|
+
const event = rest[0];
|
|
261
|
+
if (event === "working" || event === "start")
|
|
262
|
+
state = working(state);
|
|
263
|
+
else if (event === "done" || event === "celebrate")
|
|
264
|
+
state = celebrate(state);
|
|
265
|
+
else if (event === "error" || event === "scared")
|
|
266
|
+
state = scared(state);
|
|
267
|
+
else {
|
|
268
|
+
console.error(
|
|
269
|
+
"usage: opencode-buddy notify <working|done|error>",
|
|
270
|
+
);
|
|
271
|
+
process.exit(2);
|
|
272
|
+
}
|
|
273
|
+
await persistence.save(state);
|
|
274
|
+
console.log(`Notified buddy: ${event}`);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "path":
|
|
278
|
+
console.log(persistence.pathForDisplay());
|
|
279
|
+
break;
|
|
280
|
+
default:
|
|
281
|
+
printUsage();
|
|
282
|
+
process.exit(cmd ? 2 : 0);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function printUsage() {
|
|
287
|
+
console.log(`opencode-buddy — a virtual pet for your tmux session
|
|
288
|
+
|
|
289
|
+
Usage:
|
|
290
|
+
opencode-buddy start Launch interactive buddy in a tmux side pane
|
|
291
|
+
opencode-buddy hatch [species] [name] Start a brand new buddy (resets state)
|
|
292
|
+
opencode-buddy feed Feed your buddy
|
|
293
|
+
opencode-buddy play Play with your buddy
|
|
294
|
+
opencode-buddy rest Let your buddy rest
|
|
295
|
+
opencode-buddy rename <name> Give your buddy a new name
|
|
296
|
+
opencode-buddy switch <species> Change species (${SPECIES.join(", ")})
|
|
297
|
+
opencode-buddy notify <event> Notify buddy of an opencode event
|
|
298
|
+
(working | done | error)
|
|
299
|
+
opencode-buddy stats Print current buddy stats
|
|
300
|
+
opencode-buddy path Print path to state file
|
|
301
|
+
`);
|
|
302
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Persist buddy state to ~/.config/opencode-buddy/state.json
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
const DIR = path.join(os.homedir(), ".config", "opencode-buddy");
|
|
7
|
+
const FILE = path.join(DIR, "state.json");
|
|
8
|
+
|
|
9
|
+
export async function load() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await fs.readFile(FILE, "utf8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (err.code === "ENOENT") return null;
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function save(state) {
|
|
20
|
+
await fs.mkdir(DIR, { recursive: true });
|
|
21
|
+
const tmp = FILE + ".tmp";
|
|
22
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2));
|
|
23
|
+
await fs.rename(tmp, FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pathForDisplay() {
|
|
27
|
+
return FILE;
|
|
28
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// opencode plugin entry. Exposes a `buddy` tool the LLM can call and
|
|
2
|
+
// listens for session events to keep the buddy's mood in sync with what
|
|
3
|
+
// the user is actually doing.
|
|
4
|
+
//
|
|
5
|
+
// Loaded automatically by opencode when listed in `opencode.json`:
|
|
6
|
+
// { "plugin": ["opencode-buddy"] }
|
|
7
|
+
//
|
|
8
|
+
// Also installed at runtime by the `opencode-buddy install` CLI command,
|
|
9
|
+
// which writes ~/.config/opencode/commands/buddy.md (the /buddy slash
|
|
10
|
+
// command) and appends this package to opencode.json's plugin list.
|
|
11
|
+
|
|
12
|
+
import { tool } from "@opencode-ai/plugin";
|
|
13
|
+
import * as state from "./buddy/state.js";
|
|
14
|
+
import * as persistence from "./persistence.js";
|
|
15
|
+
import { SPECIES, paint, FLAVOR } from "./buddy/species.js";
|
|
16
|
+
|
|
17
|
+
const DESCRIPTION = `Interact with your virtual buddy companion. Pass an "action" argument.
|
|
18
|
+
|
|
19
|
+
Actions:
|
|
20
|
+
status - print current stats as a one-liner
|
|
21
|
+
feed - feed the buddy (+25 hunger, -5 energy)
|
|
22
|
+
play - play with the buddy (+20 happiness, -5 hunger, +5 xp)
|
|
23
|
+
rest - let the buddy rest (+30 energy)
|
|
24
|
+
switch <species> - change species (duck, cat, dragon, axolotl, robot, ghost)
|
|
25
|
+
rename <name> - rename the buddy (max 20 chars)
|
|
26
|
+
hatch [species] [name] - start a brand new buddy
|
|
27
|
+
ascii - return a rendered ASCII art of the current buddy
|
|
28
|
+
path - print path to the persistent state file
|
|
29
|
+
help - list available actions
|
|
30
|
+
|
|
31
|
+
The buddy's mood (idle/working/celebrating/scared/sleeping) is set
|
|
32
|
+
automatically based on the opencode session lifecycle. Use this tool
|
|
33
|
+
when the user asks things like "/buddy", "/buddy feed", "/buddy stats",
|
|
34
|
+
"check on my pet", "switch to a dragon", etc.`;
|
|
35
|
+
|
|
36
|
+
const SPECIES_LIST = SPECIES.join(", ");
|
|
37
|
+
|
|
38
|
+
function _summary(s) {
|
|
39
|
+
return `${s.name} the ${s.species} | Lv ${s.level} | ${s.state} | hunger ${Math.floor(s.hunger)} happiness ${Math.floor(s.happiness)} energy ${Math.floor(s.energy)} | xp ${s.xp}/${s.level * 50}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadOrInit() {
|
|
43
|
+
const s = await persistence.load();
|
|
44
|
+
if (s) return s;
|
|
45
|
+
const fresh = { ...state.DEFAULT_STATE, lastTick: Date.now() };
|
|
46
|
+
await persistence.save(fresh);
|
|
47
|
+
return fresh;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const BuddyPlugin = async ({ client }) => {
|
|
51
|
+
return {
|
|
52
|
+
// ----- Custom tool the LLM can call --------------------------------
|
|
53
|
+
tool: {
|
|
54
|
+
buddy: tool({
|
|
55
|
+
description: DESCRIPTION,
|
|
56
|
+
args: {
|
|
57
|
+
action: tool.schema
|
|
58
|
+
.string()
|
|
59
|
+
.describe(
|
|
60
|
+
`One of: status, feed, play, rest, switch, rename, hatch, ascii, path, help. For switch/rename/hatch, also pass the "species" or "name" argument as needed.`,
|
|
61
|
+
),
|
|
62
|
+
species: tool.schema
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(`Species for switch/hatch: ${SPECIES_LIST}`),
|
|
66
|
+
name: tool.schema
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe(`Name for rename/hatch (max 20 chars).`),
|
|
70
|
+
},
|
|
71
|
+
async execute(args) {
|
|
72
|
+
let s = await loadOrInit();
|
|
73
|
+
const action = (args.action || "").toLowerCase();
|
|
74
|
+
const species = (args.species || "").toLowerCase();
|
|
75
|
+
const name = args.name;
|
|
76
|
+
|
|
77
|
+
switch (action) {
|
|
78
|
+
case "":
|
|
79
|
+
case "status":
|
|
80
|
+
return _summary(s);
|
|
81
|
+
|
|
82
|
+
case "feed":
|
|
83
|
+
s = state.feed(s);
|
|
84
|
+
await persistence.save(s);
|
|
85
|
+
return `${s.name} munches happily. ${_summary(s)}`;
|
|
86
|
+
|
|
87
|
+
case "play":
|
|
88
|
+
s = state.play(s);
|
|
89
|
+
s = state.maybeLevelUp(s);
|
|
90
|
+
await persistence.save(s);
|
|
91
|
+
return `${s.name} plays! ${_summary(s)}`;
|
|
92
|
+
|
|
93
|
+
case "rest":
|
|
94
|
+
s = state.rest(s);
|
|
95
|
+
await persistence.save(s);
|
|
96
|
+
return `${s.name} curls up. ${_summary(s)}`;
|
|
97
|
+
|
|
98
|
+
case "switch": {
|
|
99
|
+
if (!species) return `Pick a species: ${SPECIES_LIST}`;
|
|
100
|
+
if (!SPECIES.includes(species))
|
|
101
|
+
return `Unknown species "${species}". Valid: ${SPECIES_LIST}`;
|
|
102
|
+
s = state.switchSpecies(s, species);
|
|
103
|
+
await persistence.save(s);
|
|
104
|
+
return `${s.name} transformed into a ${species}. ${_summary(s)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case "rename": {
|
|
108
|
+
if (!name) return `Provide a name with the "name" argument.`;
|
|
109
|
+
s = state.rename(s, name);
|
|
110
|
+
await persistence.save(s);
|
|
111
|
+
return `Renamed to ${s.name}. ${_summary(s)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case "hatch": {
|
|
115
|
+
const sp = species && SPECIES.includes(species) ? species : "duck";
|
|
116
|
+
s = {
|
|
117
|
+
...state.DEFAULT_STATE,
|
|
118
|
+
species: sp,
|
|
119
|
+
name: name || "Buddy",
|
|
120
|
+
lastTick: Date.now(),
|
|
121
|
+
};
|
|
122
|
+
await persistence.save(s);
|
|
123
|
+
return `Hatched a new ${sp} named ${s.name}. ${_summary(s)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case "ascii": {
|
|
127
|
+
const lines = paint(s.species, s.state);
|
|
128
|
+
return ["```", ...lines, "```", _summary(s)].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case "path":
|
|
132
|
+
return persistence.pathForDisplay();
|
|
133
|
+
|
|
134
|
+
case "help":
|
|
135
|
+
return DESCRIPTION;
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
return `Unknown action "${args.action}". Try: /buddy help`;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// ----- React to opencode session lifecycle -------------------------
|
|
145
|
+
event: async ({ event }) => {
|
|
146
|
+
// Auto-mood: when opencode finishes a turn, the buddy celebrates
|
|
147
|
+
// for a few seconds. When a session errors, the buddy gets scared.
|
|
148
|
+
// The visible feedback comes from the tmux sidecar picking up the
|
|
149
|
+
// updated state file, or the LLM re-rendering ASCII via the tool.
|
|
150
|
+
try {
|
|
151
|
+
if (event.type === "session.idle") {
|
|
152
|
+
const s = await loadOrInit();
|
|
153
|
+
await persistence.save(state.celebrate(s));
|
|
154
|
+
} else if (event.type === "session.error") {
|
|
155
|
+
const s = await loadOrInit();
|
|
156
|
+
await persistence.save(state.scared(s));
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (client?.app?.log) {
|
|
160
|
+
await client.app.log({
|
|
161
|
+
body: {
|
|
162
|
+
service: "opencode-buddy",
|
|
163
|
+
level: "warn",
|
|
164
|
+
message: `event hook failed: ${err.message}`,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Re-export common helpers so other tools (e.g. custom agents) can
|
|
174
|
+
// import the same state machine used by the plugin and the tmux
|
|
175
|
+
// sidecar — guaranteeing the persisted state file format stays in sync.
|
|
176
|
+
export { SPECIES, FLAVOR };
|
|
177
|
+
export * as stateMachine from "./buddy/state.js";
|
|
178
|
+
export { persistence };
|