opencode-interrupt-plugin 0.4.11 → 0.4.13
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 +66 -0
- package/dist/index.js +2 -1
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +165 -0
- package/package.json +7 -2
- package/scripts/install-whisper.sh +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# OpenCode Interrupt Plugin
|
|
2
|
+
|
|
3
|
+
Streaming TTS + voice interruption for OpenCode. Speaks responses as they arrive and detects when you talk over it. **Walkie-talkie mode** lets you hold the spacebar to redirect the model on the fly.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add both the server plugin and the TUI plugin to your `~/.config/opencode/opencode.json`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"plugin": [
|
|
12
|
+
"opencode-interrupt-plugin",
|
|
13
|
+
"opencode-interrupt-plugin/tui"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The server plugin handles TTS streaming, voice overlap detection, and interrupt injection. The TUI plugin adds the walkie-talkie keybinding (`space`).
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
### Walkie-Talkie Mode
|
|
23
|
+
|
|
24
|
+
Hold down the **spacebar** while speaking, then release it to redirect the model:
|
|
25
|
+
|
|
26
|
+
1. The model is generating a response — TTS is playing
|
|
27
|
+
2. **Press and hold** spacebar — generation aborts, TTS stops, mic starts recording
|
|
28
|
+
3. **Speak your correction** while holding spacebar
|
|
29
|
+
4. **Release** spacebar — audio is transcribed via Whisper and sent as your next message
|
|
30
|
+
5. The model responds to your correction
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
[Model speaking] → hold space → speak correction → release space → model redirects
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires [whisper.cpp](https://github.com/ggerganov/whisper.cpp) with the `base` model installed.
|
|
37
|
+
Run the one-command install script:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bash scripts/install-whisper.sh
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or set `OPENAI_API_KEY` in your environment as a fallback.
|
|
44
|
+
|
|
45
|
+
### Voice Interruption (server plugin)
|
|
46
|
+
|
|
47
|
+
When TTS is playing, just speak — the plugin detects your voice, stops TTS, and marks the session for correction injection.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
| Setting | Type | Default | Description |
|
|
52
|
+
|---------|------|---------|-------------|
|
|
53
|
+
| `licenseKey` | string | none | License key (omit for free mode) |
|
|
54
|
+
| `micThreshold` | number | 0.008 | Voice activation threshold (free mode locked) |
|
|
55
|
+
| `timingWindowMs` | number | 3000 | How long after interrupt to accept corrections |
|
|
56
|
+
|
|
57
|
+
### Environment Variables
|
|
58
|
+
|
|
59
|
+
- `OPENAI_API_KEY` — optional fallback for Whisper transcription (used if `whisper` CLI is not found)
|
|
60
|
+
- `WHISPER_MODEL` — path to whisper.cpp model file (default: `~/.local/bin/ggml-base.bin`)
|
|
61
|
+
- `EDGE_TTS_VOICE` — TTS voice (default: `en-US-AriaNeural`)
|
|
62
|
+
|
|
63
|
+
## How It Works
|
|
64
|
+
|
|
65
|
+
- **Server plugin**: Monitors mic via sox (continuous PCM pipe), detects voice during TTS playback, injects correction context into the next LLM request
|
|
66
|
+
- **TUI plugin**: Registers a `space` keybinding in OpenCode's TUI keymap; key repeats while held keep the recording active; 300ms of silence after release triggers transcription and redirect
|
package/dist/index.js
CHANGED
|
@@ -114,11 +114,12 @@ export const InterruptPlugin = (userConfig = {}) => {
|
|
|
114
114
|
if (!msg || !msg.role)
|
|
115
115
|
return;
|
|
116
116
|
if (msg.role === 'assistant') {
|
|
117
|
+
const state = getSessionState(sessionId);
|
|
117
118
|
const content = extractText(parts);
|
|
118
119
|
updateSessionState(sessionId, {
|
|
119
120
|
lastAssistantContent: content,
|
|
120
121
|
lastAssistantTimestamp: Date.now(),
|
|
121
|
-
wasInterrupted: false,
|
|
122
|
+
wasInterrupted: state.awaitingCorrection ? state.wasInterrupted : false,
|
|
122
123
|
});
|
|
123
124
|
return;
|
|
124
125
|
}
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn, execSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
const RECORDING_FILE = "/tmp/interrupt-ptt.wav";
|
|
4
|
+
const WHISPER_MODEL = process.env.WHISPER_MODEL || `${process.env.HOME}/.local/bin/ggml-base.bin`;
|
|
5
|
+
const RELEASE_TIMEOUT_MS = 300;
|
|
6
|
+
let recordingProcess = null;
|
|
7
|
+
let active = false;
|
|
8
|
+
let releaseTimer = null;
|
|
9
|
+
function pttStartRecording() {
|
|
10
|
+
if (recordingProcess)
|
|
11
|
+
return;
|
|
12
|
+
recordingProcess = spawn("sox", [
|
|
13
|
+
"-d", "--rate", "16000", "--channels", "1",
|
|
14
|
+
"--encoding", "signed-integer", "--bits", "16",
|
|
15
|
+
RECORDING_FILE,
|
|
16
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
17
|
+
recordingProcess.on("exit", () => { recordingProcess = null; });
|
|
18
|
+
recordingProcess.on("error", () => { recordingProcess = null; });
|
|
19
|
+
}
|
|
20
|
+
function pttStopRecording() {
|
|
21
|
+
if (!recordingProcess)
|
|
22
|
+
return;
|
|
23
|
+
recordingProcess.kill("SIGTERM");
|
|
24
|
+
recordingProcess = null;
|
|
25
|
+
}
|
|
26
|
+
const TXT_FILE = "/tmp/interrupt-ptt.txt";
|
|
27
|
+
async function transcribeLocal() {
|
|
28
|
+
try {
|
|
29
|
+
execSync("whisper --help", { stdio: "ignore", timeout: 3000 });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
unlinkSync(TXT_FILE);
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore */ }
|
|
38
|
+
try {
|
|
39
|
+
execSync(`whisper -f "${RECORDING_FILE}" -m "${WHISPER_MODEL}" -otxt -of /tmp/interrupt-ptt`, { stdio: "ignore", timeout: 30000 });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (existsSync(TXT_FILE)) {
|
|
45
|
+
const text = readFileSync(TXT_FILE, "utf-8").trim();
|
|
46
|
+
try {
|
|
47
|
+
unlinkSync(TXT_FILE);
|
|
48
|
+
}
|
|
49
|
+
catch { /* ignore */ }
|
|
50
|
+
return text || null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
async function transcribeAPI() {
|
|
55
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
56
|
+
if (!apiKey || !existsSync(RECORDING_FILE))
|
|
57
|
+
return null;
|
|
58
|
+
const buffer = readFileSync(RECORDING_FILE);
|
|
59
|
+
const form = new FormData();
|
|
60
|
+
form.append("file", new Blob([buffer], { type: "audio/wav" }), "recording.wav");
|
|
61
|
+
form.append("model", "whisper-1");
|
|
62
|
+
try {
|
|
63
|
+
const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
66
|
+
body: form,
|
|
67
|
+
});
|
|
68
|
+
if (!resp.ok)
|
|
69
|
+
return null;
|
|
70
|
+
const data = await resp.json();
|
|
71
|
+
return data.text;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function transcribeAndSend(sessionID, directory, api) {
|
|
78
|
+
pttStopRecording();
|
|
79
|
+
if (!existsSync(RECORDING_FILE)) {
|
|
80
|
+
api.ui.toast({ variant: "warning", title: "PTT", message: "No audio captured" });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
api.ui.toast({ variant: "info", title: "PTT", message: "Transcribing..." });
|
|
84
|
+
let text = null;
|
|
85
|
+
text = await transcribeLocal();
|
|
86
|
+
if (!text)
|
|
87
|
+
text = await transcribeAPI();
|
|
88
|
+
if (!text) {
|
|
89
|
+
api.ui.toast({
|
|
90
|
+
variant: "error",
|
|
91
|
+
title: "PTT",
|
|
92
|
+
message: "Missing whisper — run scripts/install-whisper.sh or set OPENAI_API_KEY",
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(RECORDING_FILE);
|
|
96
|
+
}
|
|
97
|
+
catch { /* ignore */ }
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(RECORDING_FILE);
|
|
102
|
+
}
|
|
103
|
+
catch { /* ignore */ }
|
|
104
|
+
if (!sessionID) {
|
|
105
|
+
api.ui.toast({ variant: "warning", title: "PTT", message: "No active session" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await api.client.session.prompt({
|
|
109
|
+
sessionID,
|
|
110
|
+
directory,
|
|
111
|
+
parts: [{ type: "text", text }],
|
|
112
|
+
});
|
|
113
|
+
api.ui.toast({ variant: "success", title: "PTT", message: `Sent: "${text.slice(0, 60)}"` });
|
|
114
|
+
}
|
|
115
|
+
const tui = async (api, _options, _meta) => {
|
|
116
|
+
api.keymap.registerLayer({
|
|
117
|
+
commands: [
|
|
118
|
+
{
|
|
119
|
+
name: "interrupt.ptt",
|
|
120
|
+
title: "Walkie-Talkie (hold space to talk, release to redirect)",
|
|
121
|
+
category: "Plugin",
|
|
122
|
+
run: async () => {
|
|
123
|
+
const route = api.route.current;
|
|
124
|
+
const sessionID = route.name === "session"
|
|
125
|
+
? route.params?.sessionID
|
|
126
|
+
: undefined;
|
|
127
|
+
const directory = api.state.path.directory;
|
|
128
|
+
if (releaseTimer) {
|
|
129
|
+
clearTimeout(releaseTimer);
|
|
130
|
+
releaseTimer = null;
|
|
131
|
+
}
|
|
132
|
+
if (!active) {
|
|
133
|
+
active = true;
|
|
134
|
+
if (sessionID) {
|
|
135
|
+
try {
|
|
136
|
+
await api.client.session.abort({ sessionID, directory });
|
|
137
|
+
}
|
|
138
|
+
catch { /* ignore */ }
|
|
139
|
+
}
|
|
140
|
+
pttStartRecording();
|
|
141
|
+
api.ui.toast({ variant: "info", title: "PTT", message: "Recording... (release space to send)" });
|
|
142
|
+
}
|
|
143
|
+
releaseTimer = setTimeout(() => {
|
|
144
|
+
releaseTimer = null;
|
|
145
|
+
if (!active)
|
|
146
|
+
return;
|
|
147
|
+
active = false;
|
|
148
|
+
transcribeAndSend(sessionID, directory, api);
|
|
149
|
+
}, RELEASE_TIMEOUT_MS);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
bindings: [{ key: "space", cmd: "interrupt.ptt" }],
|
|
154
|
+
});
|
|
155
|
+
api.lifecycle.onDispose(() => {
|
|
156
|
+
if (releaseTimer)
|
|
157
|
+
clearTimeout(releaseTimer);
|
|
158
|
+
if (recordingProcess) {
|
|
159
|
+
recordingProcess.kill("SIGTERM");
|
|
160
|
+
recordingProcess = null;
|
|
161
|
+
}
|
|
162
|
+
active = false;
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
export default { id: "interrupt.walkie-talkie", tui };
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-interrupt-plugin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.13",
|
|
4
4
|
"description": "Streaming TTS + voice interruption for OpenCode. Speaks responses as they arrive and detects when you talk over it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./tui": "./dist/tui.js"
|
|
11
|
+
},
|
|
8
12
|
"files": [
|
|
9
|
-
"dist/"
|
|
13
|
+
"dist/",
|
|
14
|
+
"scripts/"
|
|
10
15
|
],
|
|
11
16
|
"scripts": {
|
|
12
17
|
"build": "tsc",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
WHISPER_DIR="${WHISPER_DIR:-/tmp/whisper-cpp}"
|
|
5
|
+
INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/bin}"
|
|
6
|
+
MODEL="${MODEL:-base}"
|
|
7
|
+
MODEL_FILE="ggml-${MODEL}.bin"
|
|
8
|
+
|
|
9
|
+
echo "==> Installing whisper.cpp + ${MODEL} model to ${INSTALL_DIR}"
|
|
10
|
+
|
|
11
|
+
# --- clone ---
|
|
12
|
+
if [ ! -d "${WHISPER_DIR}" ]; then
|
|
13
|
+
echo "==> Cloning whisper.cpp..."
|
|
14
|
+
git clone --depth 1 https://github.com/ggerganov/whisper.cpp "${WHISPER_DIR}"
|
|
15
|
+
else
|
|
16
|
+
echo "==> whisper.cpp already cloned, updating..."
|
|
17
|
+
git -C "${WHISPER_DIR}" pull --ff-only
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# --- build ---
|
|
21
|
+
echo "==> Building..."
|
|
22
|
+
cmake -S "${WHISPER_DIR}" -B "${WHISPER_DIR}/build" -DBUILD_SHARED_LIBS=OFF
|
|
23
|
+
cmake --build "${WHISPER_DIR}/build" --config Release -j"$(nproc)"
|
|
24
|
+
|
|
25
|
+
# --- install binary ---
|
|
26
|
+
mkdir -p "${INSTALL_DIR}"
|
|
27
|
+
cp "${WHISPER_DIR}/build/bin/whisper-cli" "${INSTALL_DIR}/whisper" 2>/dev/null \
|
|
28
|
+
|| cp "${WHISPER_DIR}/build/bin/whisper" "${INSTALL_DIR}/whisper" 2>/dev/null \
|
|
29
|
+
|| { echo "ERROR: couldn't find whisper binary in build output"; exit 1; }
|
|
30
|
+
echo "==> Installed whisper to ${INSTALL_DIR}/whisper"
|
|
31
|
+
|
|
32
|
+
# --- download model ---
|
|
33
|
+
MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${MODEL_FILE}"
|
|
34
|
+
if [ ! -f "${INSTALL_DIR}/${MODEL_FILE}" ]; then
|
|
35
|
+
echo "==> Downloading ${MODEL} model (~150MB)..."
|
|
36
|
+
curl -fSL "${MODEL_URL}" -o "${INSTALL_DIR}/${MODEL_FILE}"
|
|
37
|
+
else
|
|
38
|
+
echo "==> Model already present at ${INSTALL_DIR}/${MODEL_FILE}"
|
|
39
|
+
fi
|
|
40
|
+
echo "==> Model installed to ${INSTALL_DIR}/${MODEL_FILE}"
|
|
41
|
+
|
|
42
|
+
# --- ensure INSTALL_DIR is on PATH ---
|
|
43
|
+
if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then
|
|
44
|
+
echo ""
|
|
45
|
+
echo "==> NOTE: Add ${INSTALL_DIR} to your PATH:"
|
|
46
|
+
echo " echo 'export PATH=\"\$PATH:${INSTALL_DIR}\"' >> ~/.bashrc"
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
echo ""
|
|
50
|
+
echo "==> Done! Verify with: whisper --help"
|