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.
- package/README.md +77 -156
- package/package.json +8 -20
- package/src/persistence.js +7 -7
- package/src/server-plugin.js +133 -0
- package/src/species.js +541 -0
- package/src/state.js +120 -0
- package/src/tui-plugin.jsx +156 -0
- package/bin/opencode-buddy.js +0 -45
- package/src/buddy/species.js +0 -399
- package/src/buddy/state.js +0 -168
- package/src/index.js +0 -332
- package/src/install.js +0 -157
- package/src/plugin.js +0 -178
- package/src/tmux.js +0 -90
- package/src/tui.js +0 -164
package/src/plugin.js
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
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 };
|
package/src/tmux.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// tmux integration: detect session, split a side pane, capture main pane
|
|
2
|
-
// content for activity inference.
|
|
3
|
-
//
|
|
4
|
-
// All tmux interaction goes through `tmux -t <target> <command>` so we don't
|
|
5
|
-
// have to manage a child process. We parse plain text output.
|
|
6
|
-
|
|
7
|
-
import { spawnSync } from "node:child_process";
|
|
8
|
-
|
|
9
|
-
export function inTmux() {
|
|
10
|
-
return Boolean(process.env.TMUX);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function currentPaneId() {
|
|
14
|
-
const r = spawnSync("tmux", ["display-message", "-p", "#{pane_id}"], {
|
|
15
|
-
encoding: "utf8",
|
|
16
|
-
});
|
|
17
|
-
if (r.status !== 0) return null;
|
|
18
|
-
return r.stdout.trim() || null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function sessionName() {
|
|
22
|
-
const r = spawnSync("tmux", ["display-message", "-p", "#{session_name}"], {
|
|
23
|
-
encoding: "utf8",
|
|
24
|
-
});
|
|
25
|
-
if (r.status !== 0) return null;
|
|
26
|
-
return r.stdout.trim() || null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function splitRight(command, size = 28) {
|
|
30
|
-
// -h horizontal split (side-by-side), -l size in columns, -d don't focus
|
|
31
|
-
const r = spawnSync(
|
|
32
|
-
"tmux",
|
|
33
|
-
[
|
|
34
|
-
"split-window",
|
|
35
|
-
"-h",
|
|
36
|
-
"-l",
|
|
37
|
-
String(size),
|
|
38
|
-
"-d",
|
|
39
|
-
"-P",
|
|
40
|
-
"-F",
|
|
41
|
-
"#{pane_id}",
|
|
42
|
-
command,
|
|
43
|
-
],
|
|
44
|
-
{ encoding: "utf8" },
|
|
45
|
-
);
|
|
46
|
-
if (r.status !== 0) {
|
|
47
|
-
throw new Error(`tmux split-window failed: ${r.stderr || r.stdout}`);
|
|
48
|
-
}
|
|
49
|
-
return r.stdout.trim();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function captureMainPane(lines = 80) {
|
|
53
|
-
const pane = currentPaneId();
|
|
54
|
-
if (!pane) return "";
|
|
55
|
-
const r = spawnSync(
|
|
56
|
-
"tmux",
|
|
57
|
-
["capture-pane", "-t", pane, "-p", "-S", `-${lines}`],
|
|
58
|
-
{ encoding: "utf8" },
|
|
59
|
-
);
|
|
60
|
-
if (r.status !== 0) return "";
|
|
61
|
-
return r.stdout;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Heuristic activity inference from captured pane text.
|
|
65
|
-
// Returns one of: "working" | "idle" | "error" | "unknown"
|
|
66
|
-
export function inferActivity(paneText) {
|
|
67
|
-
if (!paneText) return "unknown";
|
|
68
|
-
const tail = paneText.split("\n").slice(-40).join("\n");
|
|
69
|
-
|
|
70
|
-
// Opencode-specific: while generating, it shows a spinner phrase
|
|
71
|
-
if (/\bgenerating\b|\besc to cancel\b|\bthinking\b/i.test(tail))
|
|
72
|
-
return "working";
|
|
73
|
-
|
|
74
|
-
// Error markers
|
|
75
|
-
if ((/\berror\b|\bfailed\b|\bexception\b/i.test(tail)) &&
|
|
76
|
-
!/no error/i.test(tail)) {
|
|
77
|
-
return "error";
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Idle: looks like an empty prompt waiting for input
|
|
81
|
-
if (/^\s*>\s*$/m.test(tail) || /\bctrl\+t to view\b/i.test(tail))
|
|
82
|
-
return "idle";
|
|
83
|
-
|
|
84
|
-
return "unknown";
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function killBuddyPane(paneId) {
|
|
88
|
-
if (!paneId) return;
|
|
89
|
-
spawnSync("tmux", ["kill-pane", "-t", paneId], { encoding: "utf8" });
|
|
90
|
-
}
|
package/src/tui.js
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// TUI renderer. Pure ANSI, no third-party deps.
|
|
2
|
-
//
|
|
3
|
-
// Lifecycle:
|
|
4
|
-
// const tui = createTui()
|
|
5
|
-
// tui.start()
|
|
6
|
-
// ... on every tick:
|
|
7
|
-
// tui.render({ buddy, status, hint })
|
|
8
|
-
// tui.stop()
|
|
9
|
-
//
|
|
10
|
-
// Input handling: registers a keypress listener. Keys are mapped by
|
|
11
|
-
// `bindKey()` to user-defined callbacks.
|
|
12
|
-
|
|
13
|
-
import readline from "node:readline";
|
|
14
|
-
import { paint, frameWidth, frameHeight, FLAVOR } from "./buddy/species.js";
|
|
15
|
-
import { describe } from "./buddy/state.js";
|
|
16
|
-
|
|
17
|
-
const ESC = "\x1b";
|
|
18
|
-
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
19
|
-
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
20
|
-
const ALT_SCREEN_ON = `${ESC}[?1049h`;
|
|
21
|
-
const ALT_SCREEN_OFF = `${ESC}[?1049l`;
|
|
22
|
-
const CLEAR = `${ESC}[2J${ESC}[H`;
|
|
23
|
-
const RESET = `${ESC}[0m`;
|
|
24
|
-
const BOLD = `${ESC}[1m`;
|
|
25
|
-
const DIM = `${ESC}[2m`;
|
|
26
|
-
const CYAN = `${ESC}[36m`;
|
|
27
|
-
const YELLOW = `${ESC}[33m`;
|
|
28
|
-
const GREEN = `${ESC}[32m`;
|
|
29
|
-
const MAGENTA = `${ESC}[35m`;
|
|
30
|
-
const GRAY = `${ESC}[90m`;
|
|
31
|
-
const BRIGHT_WHITE = `${ESC}[97m`;
|
|
32
|
-
|
|
33
|
-
const FRAME_INTERVAL_MS = 200;
|
|
34
|
-
|
|
35
|
-
function pickFlavor(species, seed) {
|
|
36
|
-
const lines = FLAVOR[species] || ["..."];
|
|
37
|
-
return lines[seed % lines.length];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function bars(value, width = 20, colorFn = (i) => GREEN) {
|
|
41
|
-
const filled = Math.round((value / 100) * width);
|
|
42
|
-
return colorFn(0) + "█".repeat(filled) + GRAY + "░".repeat(width - filled) + RESET;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function buildFrame(state, opts = {}) {
|
|
46
|
-
const width = frameWidth(state.species);
|
|
47
|
-
const height = frameHeight(state.species);
|
|
48
|
-
const art = paint(state.species, state.state);
|
|
49
|
-
|
|
50
|
-
const lines = [];
|
|
51
|
-
// Top border
|
|
52
|
-
lines.push(
|
|
53
|
-
`${CYAN}┌${"─".repeat(width + 2)}┐${RESET}`,
|
|
54
|
-
);
|
|
55
|
-
// Art lines, padded to a consistent width
|
|
56
|
-
for (let i = 0; i < height; i++) {
|
|
57
|
-
const row = art[i] || " ".repeat(width);
|
|
58
|
-
lines.push(`${CYAN}│${RESET} ${row} ${CYAN}│${RESET}`);
|
|
59
|
-
}
|
|
60
|
-
// Bottom border
|
|
61
|
-
lines.push(
|
|
62
|
-
`${CYAN}└${"─".repeat(width + 2)}┘${RESET}`,
|
|
63
|
-
);
|
|
64
|
-
// Status block
|
|
65
|
-
const stateLabel = state.state.toUpperCase();
|
|
66
|
-
const reason = state.stateReason ? ` ${DIM}(${state.stateReason})${RESET}` : "";
|
|
67
|
-
lines.push(
|
|
68
|
-
` ${BOLD}${state.name}${RESET} ${DIM}the${RESET} ${MAGENTA}${state.species}${RESET} ${DIM}·${RESET} ${YELLOW}${stateLabel}${RESET}${reason}`,
|
|
69
|
-
);
|
|
70
|
-
lines.push(` ${DIM}Lv${RESET} ${state.level} ${DIM}xp${RESET} ${state.xp}/${state.level * 50}`);
|
|
71
|
-
lines.push(
|
|
72
|
-
` ${DIM}hunger${RESET} ${bars(state.hunger)} ${Math.floor(state.hunger)}`,
|
|
73
|
-
);
|
|
74
|
-
lines.push(
|
|
75
|
-
` ${DIM}happy${RESET} ${bars(state.happiness, 20, () => MAGENTA)} ${Math.floor(state.happiness)}`,
|
|
76
|
-
);
|
|
77
|
-
lines.push(
|
|
78
|
-
` ${DIM}energy${RESET} ${bars(state.energy, 20, () => CYAN)} ${Math.floor(state.energy)}`,
|
|
79
|
-
);
|
|
80
|
-
lines.push(
|
|
81
|
-
` ${DIM}flavor${RESET} ${BRIGHT_WHITE}${pickFlavor(state.species, Math.floor(Date.now() / 4000))}${RESET}`,
|
|
82
|
-
);
|
|
83
|
-
if (opts.hint) {
|
|
84
|
-
lines.push("");
|
|
85
|
-
lines.push(` ${DIM}${opts.hint}${RESET}`);
|
|
86
|
-
}
|
|
87
|
-
return lines;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function createTui({ onKey, onResize } = {}) {
|
|
91
|
-
let running = false;
|
|
92
|
-
let timer = null;
|
|
93
|
-
let lastState = null;
|
|
94
|
-
let lastHint = "";
|
|
95
|
-
let stdinRaw = false;
|
|
96
|
-
|
|
97
|
-
function ensureRawMode() {
|
|
98
|
-
if (!process.stdin.isTTY) return;
|
|
99
|
-
if (stdinRaw) return;
|
|
100
|
-
readline.emitKeypressEvents(process.stdin);
|
|
101
|
-
process.stdin.setRawMode(true);
|
|
102
|
-
process.stdin.resume();
|
|
103
|
-
stdinRaw = true;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function onData(buf) {
|
|
107
|
-
const key = buf.toString("utf8");
|
|
108
|
-
// ctrl-c: signal stop, but let main handle the actual exit
|
|
109
|
-
if (key === "\u0003") {
|
|
110
|
-
if (onKey) onKey("ctrl-c");
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (onKey) onKey(key);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function onResize() {
|
|
117
|
-
if (onResize) onResize();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function render() {
|
|
121
|
-
if (!lastState) return;
|
|
122
|
-
const out = buildFrame(lastState, { hint: lastHint }).join("\n") + "\n";
|
|
123
|
-
process.stdout.write(CLEAR + out);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return {
|
|
127
|
-
start() {
|
|
128
|
-
if (running) return;
|
|
129
|
-
running = true;
|
|
130
|
-
ensureRawMode();
|
|
131
|
-
process.stdin.on("data", onData);
|
|
132
|
-
process.stdout.on("resize", onResize);
|
|
133
|
-
process.stdout.write(ALT_SCREEN_ON + HIDE_CURSOR + CLEAR);
|
|
134
|
-
timer = setInterval(render, FRAME_INTERVAL_MS);
|
|
135
|
-
render();
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
stop() {
|
|
139
|
-
if (!running) return;
|
|
140
|
-
running = false;
|
|
141
|
-
if (timer) clearInterval(timer);
|
|
142
|
-
timer = null;
|
|
143
|
-
process.stdin.off("data", onData);
|
|
144
|
-
process.stdout.off("resize", onResize);
|
|
145
|
-
if (stdinRaw && process.stdin.isTTY) {
|
|
146
|
-
process.stdin.setRawMode(false);
|
|
147
|
-
stdinRaw = false;
|
|
148
|
-
}
|
|
149
|
-
process.stdout.write(SHOW_CURSOR + ALT_SCREEN_OFF);
|
|
150
|
-
},
|
|
151
|
-
|
|
152
|
-
render(state, hint = "") {
|
|
153
|
-
lastState = state;
|
|
154
|
-
lastHint = hint;
|
|
155
|
-
render();
|
|
156
|
-
},
|
|
157
|
-
|
|
158
|
-
// Returns the natural frame width (so callers can size the tmux pane)
|
|
159
|
-
naturalWidth: () => (lastState ? frameWidth(lastState.species) + 4 : 26),
|
|
160
|
-
naturalHeight: () => (lastState ? frameHeight(lastState.species) + 12 : 18),
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export { describe };
|