opencode-rgbify-plugin 0.1.3
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.
Potentially problematic release.
This version of opencode-rgbify-plugin might be problematic. Click here for more details.
- package/README.md +97 -0
- package/bridge/__pycache__/ble_bridge.cpython-312.pyc +0 -0
- package/bridge/__pycache__/ble_bridge.cpython-313.pyc +0 -0
- package/bridge/ble_bridge.py +779 -0
- package/bridge/install.ps1 +68 -0
- package/bridge/install.sh +67 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +231 -0
- package/package.json +33 -0
- package/src/index.ts +241 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Self-sufficient Windows installer for the RGBify bridge. Installs EVERYTHING
|
|
2
|
+
# the bridge depends on, user-level, no admin:
|
|
3
|
+
# uv — Python/venv manager (installed into ~\.local\bin if missing)
|
|
4
|
+
# Python — uv-managed interpreter
|
|
5
|
+
# bleak — BLE connection to the projector (required)
|
|
6
|
+
# miniaudio — host auralizer (bundles its own native lib; cross-platform)
|
|
7
|
+
#
|
|
8
|
+
# Idempotent: re-running is a cheap no-op (each step skips if already done).
|
|
9
|
+
#
|
|
10
|
+
# Optional: pass a target plugin root as $1 to build the venv elsewhere (e.g.
|
|
11
|
+
# after the plugin is copied into ~/.config/opencode). Defaults to this repo.
|
|
12
|
+
param([string]$PluginRoot)
|
|
13
|
+
|
|
14
|
+
$ErrorActionPreference = "Stop"
|
|
15
|
+
|
|
16
|
+
if (-not $PluginRoot) {
|
|
17
|
+
$PluginRoot = Join-Path $PSScriptRoot ".."
|
|
18
|
+
}
|
|
19
|
+
$PluginRoot = (Resolve-Path $PluginRoot).Path
|
|
20
|
+
$VenvDir = Join-Path $PluginRoot ".venv"
|
|
21
|
+
$VenvPy = Join-Path $VenvDir "Scripts\python.exe"
|
|
22
|
+
|
|
23
|
+
function Say($msg) { Write-Host "== $msg" }
|
|
24
|
+
|
|
25
|
+
# ── 1 — uv (installs Python + manages the venv, no admin needed) ──
|
|
26
|
+
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
|
27
|
+
Say "uv already installed: $(uv --version)"
|
|
28
|
+
} else {
|
|
29
|
+
Say "Installing uv (user-level)..."
|
|
30
|
+
irm https://astral.sh/uv/install.ps1 | iex
|
|
31
|
+
# uv installs into ~\.local\bin; add to this process's PATH
|
|
32
|
+
$env:PATH = "$env:USERPROFILE\.local\bin;$env:PATH"
|
|
33
|
+
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
34
|
+
throw "uv install failed"
|
|
35
|
+
}
|
|
36
|
+
Say "uv installed: $(uv --version)"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# ── 2 — Python (uv-managed, user-level, no admin) ──
|
|
40
|
+
$PyVer = if ($env:RGBIFY_PYTHON) { $env:RGBIFY_PYTHON } else { "3.12" }
|
|
41
|
+
if (Test-Path $VenvPy) {
|
|
42
|
+
Say "venv already present: $VenvDir"
|
|
43
|
+
} else {
|
|
44
|
+
Say "Ensuring Python $PyVer via uv..."
|
|
45
|
+
uv python install $PyVer | Out-Null
|
|
46
|
+
Say "Creating venv with uv at $VenvDir"
|
|
47
|
+
uv venv --python $PyVer $VenvDir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ── 3 — Install deps ──
|
|
51
|
+
Say "Installing bridge deps into venv..."
|
|
52
|
+
uv pip install --python $VenvPy bleak miniaudio
|
|
53
|
+
|
|
54
|
+
# ── 4 — Verify imports (-B: no bytecode cache, keeps the tree clean) ──
|
|
55
|
+
& $VenvPy -B -c "import bleak" 2>$null
|
|
56
|
+
if ($LASTEXITCODE -eq 0) {
|
|
57
|
+
Say "bleak OK"
|
|
58
|
+
} else {
|
|
59
|
+
throw "bleak failed to import"
|
|
60
|
+
}
|
|
61
|
+
& $VenvPy -B -c "import miniaudio" 2>$null
|
|
62
|
+
if ($LASTEXITCODE -eq 0) {
|
|
63
|
+
Say "miniaudio OK"
|
|
64
|
+
} else {
|
|
65
|
+
Write-Host "WARN: miniaudio unavailable - host auralizer disabled, projector still works"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Say "Done. Bridge venv ready at $VenvDir"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Self-sufficient installer for the RGBify bridge. Installs EVERYTHING the
|
|
3
|
+
# bridge depends on, user-level, no root:
|
|
4
|
+
# uv — Python/venv manager (installed into ~/.local/bin if missing)
|
|
5
|
+
# Python — uv-managed interpreter (into ~/.local/share/uv/python)
|
|
6
|
+
# bleak — BLE connection to the projector (required)
|
|
7
|
+
# miniaudio — host auralizer (bundles its own native lib; cross-platform)
|
|
8
|
+
#
|
|
9
|
+
# Idempotent: re-running is a cheap no-op (each step skips if already done).
|
|
10
|
+
#
|
|
11
|
+
# Optional: pass a target plugin root as $1 to build the venv elsewhere (e.g.
|
|
12
|
+
# after the plugin is copied into ~/.config/opencode). Defaults to this repo.
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
16
|
+
PLUGIN_ROOT="${1:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
|
17
|
+
VENV_DIR="$PLUGIN_ROOT/.venv"
|
|
18
|
+
VENV_PY="$VENV_DIR/bin/python"
|
|
19
|
+
|
|
20
|
+
# UV is installed to ~/.local/bin; make sure it's on PATH for this shell even
|
|
21
|
+
# if it was just installed by this script.
|
|
22
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
23
|
+
|
|
24
|
+
say() { echo "== $*"; }
|
|
25
|
+
|
|
26
|
+
# ── 1 — uv (installs Python + manages the venv, no root needed) ──
|
|
27
|
+
if command -v uv >/dev/null 2>&1; then
|
|
28
|
+
say "uv already installed: $(uv --version)"
|
|
29
|
+
else
|
|
30
|
+
say "Installing uv (user-level)..."
|
|
31
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
32
|
+
command -v uv >/dev/null 2>&1 || {
|
|
33
|
+
echo "ERROR: uv install failed" >&2
|
|
34
|
+
exit 1
|
|
35
|
+
}
|
|
36
|
+
say "uv installed: $(uv --version)"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# ── 2 — Python (uv-managed, into ~/.local/share/uv/python, no root) ──
|
|
40
|
+
PYVER="${RGBIFY_PYTHON:-3.12}"
|
|
41
|
+
if "$VENV_PY" -c 'import sys; sys.exit(0)' >/dev/null 2>&1; then
|
|
42
|
+
say "venv already present: $VENV_DIR"
|
|
43
|
+
else
|
|
44
|
+
say "Ensuring Python $PYVER via uv..."
|
|
45
|
+
uv python install "$PYVER" || true # no-op if uv already has it
|
|
46
|
+
say "Creating venv with uv at $VENV_DIR"
|
|
47
|
+
uv venv --python "$PYVER" "$VENV_DIR"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# ── 3 — Install deps ──
|
|
51
|
+
say "Installing bridge deps into venv..."
|
|
52
|
+
uv pip install --python "$VENV_PY" bleak miniaudio
|
|
53
|
+
|
|
54
|
+
# ── 4 — Verify imports (-B: no bytecode cache, keeps the tree clean) ──
|
|
55
|
+
if "$VENV_PY" -B -c 'import bleak' 2>/dev/null; then
|
|
56
|
+
say "bleak OK"
|
|
57
|
+
else
|
|
58
|
+
echo "ERROR: bleak failed to import — check the install above" >&2
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
if "$VENV_PY" -B -c 'import miniaudio' 2>/dev/null; then
|
|
62
|
+
say "miniaudio OK"
|
|
63
|
+
else
|
|
64
|
+
echo "WARN: miniaudio unavailable — host auralizer disabled, projector still works" >&2
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
say "Done. Bridge venv ready at $VENV_DIR"
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { existsSync, appendFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const PLUGIN_ROOT = path.join(here, "..");
|
|
7
|
+
const BRIDGE = path.join(here, "..", "bridge", "ble_bridge.py");
|
|
8
|
+
// Cross-platform bootstrap: uv lays out the venv differently on Windows
|
|
9
|
+
// (.venv\Scripts\python.exe vs .venv/bin/python) and there is no bash by
|
|
10
|
+
// default, so both the interpreter path and the installer script are chosen
|
|
11
|
+
// by platform.
|
|
12
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
13
|
+
const BRIDGE_INSTALL = IS_WINDOWS
|
|
14
|
+
? path.join(here, "..", "bridge", "install.ps1")
|
|
15
|
+
: path.join(here, "..", "bridge", "install.sh");
|
|
16
|
+
const VENV_PYTHON = IS_WINDOWS
|
|
17
|
+
? path.join(here, "..", ".venv", "Scripts", "python.exe")
|
|
18
|
+
: path.join(here, "..", ".venv", "bin", "python");
|
|
19
|
+
const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG;
|
|
20
|
+
function debug(line) {
|
|
21
|
+
if (!DEBUG_LOG)
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`);
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
}
|
|
28
|
+
// Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
|
|
29
|
+
// platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
|
|
30
|
+
// the user, no root/admin. A shared promise guards against concurrent send()s
|
|
31
|
+
// triggering a duplicate install. Re-running the installer is idempotent.
|
|
32
|
+
let bootstrapPromise = null;
|
|
33
|
+
async function bootstrapPython() {
|
|
34
|
+
if (existsSync(VENV_PYTHON))
|
|
35
|
+
return VENV_PYTHON;
|
|
36
|
+
if (bootstrapPromise)
|
|
37
|
+
return bootstrapPromise;
|
|
38
|
+
bootstrapPromise = (async () => {
|
|
39
|
+
debug("bootstrap: bridge venv missing, running installer");
|
|
40
|
+
try {
|
|
41
|
+
// On Windows there's no bash by default; run the .ps1 via powershell.
|
|
42
|
+
const args = IS_WINDOWS
|
|
43
|
+
? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", BRIDGE_INSTALL, PLUGIN_ROOT]
|
|
44
|
+
: ["bash", BRIDGE_INSTALL, PLUGIN_ROOT];
|
|
45
|
+
const proc = spawn(args, {
|
|
46
|
+
stdin: "pipe",
|
|
47
|
+
stdout: "pipe",
|
|
48
|
+
stderr: "pipe",
|
|
49
|
+
});
|
|
50
|
+
void proc.stdout?.pipeTo(new WritableStream({ write() { } }));
|
|
51
|
+
void proc.stderr?.pipeTo(new WritableStream({ write() { } }));
|
|
52
|
+
const exitCode = await proc.exited;
|
|
53
|
+
debug(`bootstrap: installer exited ${exitCode}`);
|
|
54
|
+
return existsSync(VENV_PYTHON) ? VENV_PYTHON : null;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
debug(`bootstrap: installer failed: ${err}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
return bootstrapPromise;
|
|
62
|
+
}
|
|
63
|
+
function isEnabled() {
|
|
64
|
+
return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true";
|
|
65
|
+
}
|
|
66
|
+
// NO sanitization — raw delta text goes straight to the bridge. Both auralizers
|
|
67
|
+
// tolerate any byte (out-of-range chars play as rests), so nothing can crash or
|
|
68
|
+
// wedge. History: sanitize() existed for the scrolling-display era, where tags
|
|
69
|
+
// in the text would scroll across the 8x8 matrix; the firmware now blits one
|
|
70
|
+
// char at a time, so tags are harmless. Worse, the old tag-swallowing state
|
|
71
|
+
// machine could stick on an unbalanced `<` and silently discard entire streams
|
|
72
|
+
// for minutes — the long-standing "both auralizers go silent" bug. Raw is both
|
|
73
|
+
// simpler and poison-proof.
|
|
74
|
+
export const RGBifyProjectorPlugin = async ({ client }) => {
|
|
75
|
+
if (!isEnabled())
|
|
76
|
+
return {};
|
|
77
|
+
let procPromise = null;
|
|
78
|
+
const seenEventTypes = new Set();
|
|
79
|
+
function startBridge() {
|
|
80
|
+
if (procPromise)
|
|
81
|
+
return procPromise;
|
|
82
|
+
procPromise = (async () => {
|
|
83
|
+
const python = await bootstrapPython();
|
|
84
|
+
if (!python)
|
|
85
|
+
throw new Error("bridge deps not installed (install.sh failed)");
|
|
86
|
+
const proc = spawn([python, BRIDGE], {
|
|
87
|
+
stdin: "pipe",
|
|
88
|
+
stdout: "pipe",
|
|
89
|
+
stderr: "pipe",
|
|
90
|
+
// Skip BLE discovery (a ~5s scan) on every connect: default to the
|
|
91
|
+
// projector's fixed address unless the user overrides it.
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
RGBIFY_PROJECTOR_ADDR: process.env.RGBIFY_PROJECTOR_ADDR || "40:91:51:AB:50:CE",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
void proc.stdout?.pipeTo(new WritableStream({ write() { } }));
|
|
98
|
+
void proc.stderr?.pipeTo(new WritableStream({ write() { } }));
|
|
99
|
+
// proc.exited RESOLVES (with the exit code) on exit — it does NOT reject,
|
|
100
|
+
// so a `.catch` would never fire and the bridge would never respawn. Use
|
|
101
|
+
// `.finally` so a dead bridge is replaced by the next send().
|
|
102
|
+
void proc.exited.finally(() => {
|
|
103
|
+
procPromise = null;
|
|
104
|
+
});
|
|
105
|
+
return proc;
|
|
106
|
+
})();
|
|
107
|
+
return procPromise;
|
|
108
|
+
}
|
|
109
|
+
// Delivery: raw delta text is COALESCED into full-length messages. opencode
|
|
110
|
+
// emits deltas in bursts of tiny fragments (avg ~4 chars); per-delta sends
|
|
111
|
+
// produced 140ms notes separated by 600-800ms holes. Instead, accumulate
|
|
112
|
+
// text and emit a full TAIL_CHARS message whenever the buffer fills — or
|
|
113
|
+
// after FLUSH_MS of quiet, so tails aren't lost and sound stops promptly
|
|
114
|
+
// when printing stops. The bridge chains whatever is queued straight after
|
|
115
|
+
// each ACK, so during continuous printing the notes play back-to-back.
|
|
116
|
+
const TAIL_CHARS = 8;
|
|
117
|
+
const FLUSH_MS = 150;
|
|
118
|
+
let buf = "";
|
|
119
|
+
let flushTimer = null;
|
|
120
|
+
function flushBuf() {
|
|
121
|
+
if (flushTimer) {
|
|
122
|
+
clearTimeout(flushTimer);
|
|
123
|
+
flushTimer = null;
|
|
124
|
+
}
|
|
125
|
+
const line = buf.slice(-TAIL_CHARS);
|
|
126
|
+
buf = "";
|
|
127
|
+
if (line)
|
|
128
|
+
writeLine(line);
|
|
129
|
+
}
|
|
130
|
+
function writeLine(line) {
|
|
131
|
+
debug(`send len=${line.length}`);
|
|
132
|
+
startBridge()
|
|
133
|
+
.then((proc) => {
|
|
134
|
+
// We always spawn the bridge with stdin: "pipe", so it's a FileSink.
|
|
135
|
+
const stdin = proc.stdin;
|
|
136
|
+
stdin.write(line + "\n");
|
|
137
|
+
// Bun's FileSink buffers writes; without flush() the bridge receives
|
|
138
|
+
// them in delayed bursts, desyncing the projector/host from opencode.
|
|
139
|
+
const r = stdin.flush();
|
|
140
|
+
if (r && typeof r.then === "function") {
|
|
141
|
+
;
|
|
142
|
+
r.catch(() => { });
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
.catch(async (err) => {
|
|
146
|
+
await client.app.log({
|
|
147
|
+
body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function send(text) {
|
|
152
|
+
// Raw text, coalesced — no sanitization (see the note above).
|
|
153
|
+
buf += text;
|
|
154
|
+
if (buf.length >= TAIL_CHARS) {
|
|
155
|
+
flushBuf();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!flushTimer)
|
|
159
|
+
flushTimer = setTimeout(flushBuf, FLUSH_MS);
|
|
160
|
+
}
|
|
161
|
+
startBridge().catch(async (err) => {
|
|
162
|
+
await client.app.log({
|
|
163
|
+
body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
event: async ({ event }) => {
|
|
168
|
+
// The installed @opencode-ai/plugin types (v1.17.0) don't yet declare the
|
|
169
|
+
// token-level delta events we consume, so treat the event loosely here.
|
|
170
|
+
const t = event.type;
|
|
171
|
+
if (!seenEventTypes.has(t)) {
|
|
172
|
+
seenEventTypes.add(t);
|
|
173
|
+
debug(`event type=${t}`);
|
|
174
|
+
}
|
|
175
|
+
// Token-level streaming deltas — fire per-token as the LLM streams,
|
|
176
|
+
// before opencode renders the accumulated part. Small, so the projector
|
|
177
|
+
// keeps up and stays in sync with the session.
|
|
178
|
+
if (t === "message.part.delta") {
|
|
179
|
+
const p = event.properties;
|
|
180
|
+
// Only stream text/reasoning deltas. Tool-part deltas carry opencode
|
|
181
|
+
// internal tool-call JSON (messageID/callID/...) that would render as
|
|
182
|
+
// garbage on the projector; tool lifecycle is signalled separately.
|
|
183
|
+
if (p?.field === "text" || p?.field === "reasoning") {
|
|
184
|
+
if (typeof p.delta === "string" && p.delta) {
|
|
185
|
+
send(p.delta);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (t === "session.next.text.delta" || t === "session.next.reasoning.delta") {
|
|
191
|
+
const p = event.properties;
|
|
192
|
+
if (typeof p?.delta === "string" && p.delta) {
|
|
193
|
+
send(p.delta);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"chat.message": async (_input, output) => {
|
|
199
|
+
for (const part of output.parts) {
|
|
200
|
+
if (part.type !== "text")
|
|
201
|
+
continue;
|
|
202
|
+
send(part.text);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"tool.execute.before": async (input) => {
|
|
206
|
+
send("tool IN");
|
|
207
|
+
},
|
|
208
|
+
"tool.execute.after": async () => {
|
|
209
|
+
send("tool out");
|
|
210
|
+
},
|
|
211
|
+
// When opencode shuts down, kill the bridge so it doesn't linger as an
|
|
212
|
+
// orphan holding the projector connection. Closing its stdin would also do
|
|
213
|
+
// it (the bridge exits on EOF), but kill is immediate and explicit.
|
|
214
|
+
dispose: async () => {
|
|
215
|
+
// Drop any pending coalesced text — the session is over.
|
|
216
|
+
if (flushTimer) {
|
|
217
|
+
clearTimeout(flushTimer);
|
|
218
|
+
flushTimer = null;
|
|
219
|
+
}
|
|
220
|
+
buf = "";
|
|
221
|
+
if (procPromise) {
|
|
222
|
+
try {
|
|
223
|
+
const proc = await procPromise;
|
|
224
|
+
proc.kill();
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
procPromise = null;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-rgbify-plugin",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "opencode plugin: stream chat text deltas to an RGBify 8x8 projector over BLE",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"bridge",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@opencode-ai/plugin": "*"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@opencode-ai/plugin": "^1.17.0",
|
|
29
|
+
"@types/bun": "^1.4.0",
|
|
30
|
+
"@types/node": "^26.3.0",
|
|
31
|
+
"typescript": "^5.9.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { spawn } from "bun"
|
|
3
|
+
import { existsSync, appendFileSync } from "node:fs"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const PLUGIN_ROOT = path.join(here, "..")
|
|
9
|
+
const BRIDGE = path.join(here, "..", "bridge", "ble_bridge.py")
|
|
10
|
+
// Cross-platform bootstrap: uv lays out the venv differently on Windows
|
|
11
|
+
// (.venv\Scripts\python.exe vs .venv/bin/python) and there is no bash by
|
|
12
|
+
// default, so both the interpreter path and the installer script are chosen
|
|
13
|
+
// by platform.
|
|
14
|
+
const IS_WINDOWS = process.platform === "win32"
|
|
15
|
+
const BRIDGE_INSTALL = IS_WINDOWS
|
|
16
|
+
? path.join(here, "..", "bridge", "install.ps1")
|
|
17
|
+
: path.join(here, "..", "bridge", "install.sh")
|
|
18
|
+
const VENV_PYTHON = IS_WINDOWS
|
|
19
|
+
? path.join(here, "..", ".venv", "Scripts", "python.exe")
|
|
20
|
+
: path.join(here, "..", ".venv", "bin", "python")
|
|
21
|
+
const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG
|
|
22
|
+
|
|
23
|
+
function debug(line: string) {
|
|
24
|
+
if (!DEBUG_LOG) return
|
|
25
|
+
try {
|
|
26
|
+
appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`)
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
|
|
31
|
+
// platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
|
|
32
|
+
// the user, no root/admin. A shared promise guards against concurrent send()s
|
|
33
|
+
// triggering a duplicate install. Re-running the installer is idempotent.
|
|
34
|
+
let bootstrapPromise: Promise<string | null> | null = null
|
|
35
|
+
|
|
36
|
+
async function bootstrapPython(): Promise<string | null> {
|
|
37
|
+
if (existsSync(VENV_PYTHON)) return VENV_PYTHON
|
|
38
|
+
if (bootstrapPromise) return bootstrapPromise
|
|
39
|
+
bootstrapPromise = (async () => {
|
|
40
|
+
debug("bootstrap: bridge venv missing, running installer")
|
|
41
|
+
try {
|
|
42
|
+
// On Windows there's no bash by default; run the .ps1 via powershell.
|
|
43
|
+
const args = IS_WINDOWS
|
|
44
|
+
? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", BRIDGE_INSTALL, PLUGIN_ROOT]
|
|
45
|
+
: ["bash", BRIDGE_INSTALL, PLUGIN_ROOT]
|
|
46
|
+
const proc = spawn(args, {
|
|
47
|
+
stdin: "pipe",
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "pipe",
|
|
50
|
+
})
|
|
51
|
+
void proc.stdout?.pipeTo(new WritableStream({ write() {} }))
|
|
52
|
+
void proc.stderr?.pipeTo(new WritableStream({ write() {} }))
|
|
53
|
+
const exitCode = await proc.exited
|
|
54
|
+
debug(`bootstrap: installer exited ${exitCode}`)
|
|
55
|
+
return existsSync(VENV_PYTHON) ? VENV_PYTHON : null
|
|
56
|
+
} catch (err) {
|
|
57
|
+
debug(`bootstrap: installer failed: ${err}`)
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
})()
|
|
61
|
+
return bootstrapPromise
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isEnabled(): boolean {
|
|
65
|
+
return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// NO sanitization — raw delta text goes straight to the bridge. Both auralizers
|
|
69
|
+
// tolerate any byte (out-of-range chars play as rests), so nothing can crash or
|
|
70
|
+
// wedge. History: sanitize() existed for the scrolling-display era, where tags
|
|
71
|
+
// in the text would scroll across the 8x8 matrix; the firmware now blits one
|
|
72
|
+
// char at a time, so tags are harmless. Worse, the old tag-swallowing state
|
|
73
|
+
// machine could stick on an unbalanced `<` and silently discard entire streams
|
|
74
|
+
// for minutes — the long-standing "both auralizers go silent" bug. Raw is both
|
|
75
|
+
// simpler and poison-proof.
|
|
76
|
+
|
|
77
|
+
export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
|
|
78
|
+
if (!isEnabled()) return {}
|
|
79
|
+
|
|
80
|
+
let procPromise: Promise<ReturnType<typeof spawn>> | null = null
|
|
81
|
+
const seenEventTypes = new Set<string>()
|
|
82
|
+
|
|
83
|
+
function startBridge(): Promise<ReturnType<typeof spawn>> {
|
|
84
|
+
if (procPromise) return procPromise
|
|
85
|
+
procPromise = (async () => {
|
|
86
|
+
const python = await bootstrapPython()
|
|
87
|
+
if (!python) throw new Error("bridge deps not installed (install.sh failed)")
|
|
88
|
+
const proc = spawn([python, BRIDGE], {
|
|
89
|
+
stdin: "pipe",
|
|
90
|
+
stdout: "pipe",
|
|
91
|
+
stderr: "pipe",
|
|
92
|
+
// Skip BLE discovery (a ~5s scan) on every connect: default to the
|
|
93
|
+
// projector's fixed address unless the user overrides it.
|
|
94
|
+
env: {
|
|
95
|
+
...process.env,
|
|
96
|
+
RGBIFY_PROJECTOR_ADDR:
|
|
97
|
+
process.env.RGBIFY_PROJECTOR_ADDR || "40:91:51:AB:50:CE",
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
void proc.stdout?.pipeTo(new WritableStream({ write() {} }))
|
|
101
|
+
void proc.stderr?.pipeTo(
|
|
102
|
+
new WritableStream({ write() {} }),
|
|
103
|
+
)
|
|
104
|
+
// proc.exited RESOLVES (with the exit code) on exit — it does NOT reject,
|
|
105
|
+
// so a `.catch` would never fire and the bridge would never respawn. Use
|
|
106
|
+
// `.finally` so a dead bridge is replaced by the next send().
|
|
107
|
+
void proc.exited.finally(() => {
|
|
108
|
+
procPromise = null
|
|
109
|
+
})
|
|
110
|
+
return proc
|
|
111
|
+
})()
|
|
112
|
+
return procPromise
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Delivery: raw delta text is COALESCED into full-length messages. opencode
|
|
116
|
+
// emits deltas in bursts of tiny fragments (avg ~4 chars); per-delta sends
|
|
117
|
+
// produced 140ms notes separated by 600-800ms holes. Instead, accumulate
|
|
118
|
+
// text and emit a full TAIL_CHARS message whenever the buffer fills — or
|
|
119
|
+
// after FLUSH_MS of quiet, so tails aren't lost and sound stops promptly
|
|
120
|
+
// when printing stops. The bridge chains whatever is queued straight after
|
|
121
|
+
// each ACK, so during continuous printing the notes play back-to-back.
|
|
122
|
+
const TAIL_CHARS = 8
|
|
123
|
+
const FLUSH_MS = 150
|
|
124
|
+
let buf = ""
|
|
125
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
126
|
+
|
|
127
|
+
function flushBuf() {
|
|
128
|
+
if (flushTimer) {
|
|
129
|
+
clearTimeout(flushTimer)
|
|
130
|
+
flushTimer = null
|
|
131
|
+
}
|
|
132
|
+
const line = buf.slice(-TAIL_CHARS)
|
|
133
|
+
buf = ""
|
|
134
|
+
if (line) writeLine(line)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeLine(line: string) {
|
|
138
|
+
debug(`send len=${line.length}`)
|
|
139
|
+
startBridge()
|
|
140
|
+
.then((proc) => {
|
|
141
|
+
// We always spawn the bridge with stdin: "pipe", so it's a FileSink.
|
|
142
|
+
const stdin = proc.stdin as unknown as {
|
|
143
|
+
write(s: string): void
|
|
144
|
+
flush(): number | Promise<number>
|
|
145
|
+
}
|
|
146
|
+
stdin.write(line + "\n")
|
|
147
|
+
// Bun's FileSink buffers writes; without flush() the bridge receives
|
|
148
|
+
// them in delayed bursts, desyncing the projector/host from opencode.
|
|
149
|
+
const r = stdin.flush()
|
|
150
|
+
if (r && typeof (r as Promise<number>).then === "function") {
|
|
151
|
+
;(r as Promise<number>).catch(() => {})
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
.catch(async (err) => {
|
|
155
|
+
await client.app.log({
|
|
156
|
+
body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function send(text: string) {
|
|
162
|
+
// Raw text, coalesced — no sanitization (see the note above).
|
|
163
|
+
buf += text
|
|
164
|
+
if (buf.length >= TAIL_CHARS) {
|
|
165
|
+
flushBuf()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (!flushTimer) flushTimer = setTimeout(flushBuf, FLUSH_MS)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
startBridge().catch(async (err) => {
|
|
172
|
+
await client.app.log({
|
|
173
|
+
body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
event: async ({ event }) => {
|
|
179
|
+
// The installed @opencode-ai/plugin types (v1.17.0) don't yet declare the
|
|
180
|
+
// token-level delta events we consume, so treat the event loosely here.
|
|
181
|
+
const t = (event as { type: string }).type
|
|
182
|
+
if (!seenEventTypes.has(t)) {
|
|
183
|
+
seenEventTypes.add(t)
|
|
184
|
+
debug(`event type=${t}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Token-level streaming deltas — fire per-token as the LLM streams,
|
|
188
|
+
// before opencode renders the accumulated part. Small, so the projector
|
|
189
|
+
// keeps up and stays in sync with the session.
|
|
190
|
+
if (t === "message.part.delta") {
|
|
191
|
+
const p = (event as { properties?: { field?: string; delta?: string } }).properties
|
|
192
|
+
// Only stream text/reasoning deltas. Tool-part deltas carry opencode
|
|
193
|
+
// internal tool-call JSON (messageID/callID/...) that would render as
|
|
194
|
+
// garbage on the projector; tool lifecycle is signalled separately.
|
|
195
|
+
if (p?.field === "text" || p?.field === "reasoning") {
|
|
196
|
+
if (typeof p.delta === "string" && p.delta) {
|
|
197
|
+
send(p.delta)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (t === "session.next.text.delta" || t === "session.next.reasoning.delta") {
|
|
203
|
+
const p = (event as { properties?: { delta?: string } }).properties
|
|
204
|
+
if (typeof p?.delta === "string" && p.delta) {
|
|
205
|
+
send(p.delta)
|
|
206
|
+
}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"chat.message": async (_input, output) => {
|
|
211
|
+
for (const part of output.parts) {
|
|
212
|
+
if (part.type !== "text") continue
|
|
213
|
+
send(part.text)
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
"tool.execute.before": async (input) => {
|
|
217
|
+
send("tool IN")
|
|
218
|
+
},
|
|
219
|
+
"tool.execute.after": async () => {
|
|
220
|
+
send("tool out")
|
|
221
|
+
},
|
|
222
|
+
// When opencode shuts down, kill the bridge so it doesn't linger as an
|
|
223
|
+
// orphan holding the projector connection. Closing its stdin would also do
|
|
224
|
+
// it (the bridge exits on EOF), but kill is immediate and explicit.
|
|
225
|
+
dispose: async () => {
|
|
226
|
+
// Drop any pending coalesced text — the session is over.
|
|
227
|
+
if (flushTimer) {
|
|
228
|
+
clearTimeout(flushTimer)
|
|
229
|
+
flushTimer = null
|
|
230
|
+
}
|
|
231
|
+
buf = ""
|
|
232
|
+
if (procPromise) {
|
|
233
|
+
try {
|
|
234
|
+
const proc = await procPromise
|
|
235
|
+
proc.kill()
|
|
236
|
+
} catch {}
|
|
237
|
+
procPromise = null
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
}
|