opencode-buddy 0.2.0 → 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/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 };