opencode-interrupt-plugin 0.4.21 → 0.4.24

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/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { Plugin } from '@opencode-ai/plugin';
2
+ import type { TuiPlugin } from '@opencode-ai/plugin/tui';
2
3
  import { type PluginConfig } from './config.js';
3
4
  export declare const InterruptPlugin: (userConfig?: PluginConfig) => Plugin;
4
- declare const _default: Plugin;
5
+ declare const _default: {
6
+ id: string;
7
+ tui: TuiPlugin;
8
+ };
5
9
  export default _default;
6
- export { InterruptPlugin as Interrupt };
package/dist/index.js CHANGED
@@ -7,8 +7,112 @@ import { onTTSStart, onTTSEnd, isTTSTool } from './audio/tts-tracker.js';
7
7
  import { VoiceOverlapDetector } from './audio/overlap.js';
8
8
  import { detectTextInterruption } from './detector.js';
9
9
  import { TTSStreamer } from './tts/index.js';
10
+ import { spawn, execSync } from "node:child_process";
11
+ import { readFileSync, existsSync, unlinkSync } from "node:fs";
10
12
  let activeSessionId = null;
11
13
  let pendingInterrupt = null;
14
+ const RECORDING_FILE = "/tmp/interrupt-ptt.wav";
15
+ const WHISPER_MODEL = process.env.WHISPER_MODEL || `${process.env.HOME}/.local/bin/ggml-base.bin`;
16
+ let recordingProcess = null;
17
+ let pttActive = false;
18
+ function pttStartRecording() {
19
+ if (recordingProcess)
20
+ return;
21
+ recordingProcess = spawn("sox", [
22
+ "-d", "--rate", "16000", "--channels", "1",
23
+ "--encoding", "signed-integer", "--bits", "16",
24
+ RECORDING_FILE,
25
+ ], { stdio: ["ignore", "pipe", "pipe"] });
26
+ recordingProcess.on("exit", () => { recordingProcess = null; });
27
+ recordingProcess.on("error", () => { recordingProcess = null; });
28
+ }
29
+ function pttStopRecording() {
30
+ if (!recordingProcess)
31
+ return;
32
+ recordingProcess.kill("SIGTERM");
33
+ recordingProcess = null;
34
+ }
35
+ const TXT_FILE = "/tmp/interrupt-ptt.txt";
36
+ async function transcribeLocal() {
37
+ try {
38
+ execSync("whisper --help", { stdio: "ignore", timeout: 3000 });
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ try {
44
+ unlinkSync(TXT_FILE);
45
+ }
46
+ catch { /* ignore */ }
47
+ try {
48
+ execSync(`whisper -f "${RECORDING_FILE}" -m "${WHISPER_MODEL}" -otxt -of /tmp/interrupt-ptt`, { stdio: "ignore", timeout: 30000 });
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ if (existsSync(TXT_FILE)) {
54
+ const text = readFileSync(TXT_FILE, "utf-8").trim();
55
+ try {
56
+ unlinkSync(TXT_FILE);
57
+ }
58
+ catch { /* ignore */ }
59
+ return text || null;
60
+ }
61
+ return null;
62
+ }
63
+ async function transcribeAPI() {
64
+ const apiKey = process.env.OPENAI_API_KEY;
65
+ if (!apiKey || !existsSync(RECORDING_FILE))
66
+ return null;
67
+ const buffer = readFileSync(RECORDING_FILE);
68
+ const form = new FormData();
69
+ form.append("file", new Blob([buffer], { type: "audio/wav" }), "recording.wav");
70
+ form.append("model", "whisper-1");
71
+ try {
72
+ const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
73
+ method: "POST",
74
+ headers: { Authorization: `Bearer ${apiKey}` },
75
+ body: form,
76
+ });
77
+ if (!resp.ok)
78
+ return null;
79
+ const data = await resp.json();
80
+ return data.text;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ async function transcribeAndSend(sessionID, directory, api) {
87
+ pttStopRecording();
88
+ if (!existsSync(RECORDING_FILE)) {
89
+ api.ui.toast({ variant: "warning", title: "PTT", message: "No audio captured" });
90
+ return;
91
+ }
92
+ api.ui.toast({ variant: "info", title: "PTT", message: "Transcribing..." });
93
+ let text = null;
94
+ text = await transcribeLocal();
95
+ if (!text)
96
+ text = await transcribeAPI();
97
+ if (!text) {
98
+ api.ui.toast({ variant: "error", title: "PTT", message: "Missing whisper — run scripts/install-whisper.sh or set OPENAI_API_KEY" });
99
+ try {
100
+ unlinkSync(RECORDING_FILE);
101
+ }
102
+ catch { /* ignore */ }
103
+ return;
104
+ }
105
+ try {
106
+ unlinkSync(RECORDING_FILE);
107
+ }
108
+ catch { /* ignore */ }
109
+ if (!sessionID) {
110
+ api.ui.toast({ variant: "warning", title: "PTT", message: "No active session" });
111
+ return;
112
+ }
113
+ await api.client.session.prompt({ sessionID, directory, parts: [{ type: "text", text }] });
114
+ api.ui.toast({ variant: "success", title: "PTT", message: `Sent: "${text.slice(0, 60)}"` });
115
+ }
12
116
  const TTS_COMMANDS = [
13
117
  { name: 'tts-on', description: 'Enable streaming TTS', template: 'TTS enabled.' },
14
118
  { name: 'tts-off', description: 'Disable streaming TTS', template: 'TTS disabled.' },
@@ -289,5 +393,55 @@ function extractText(parts) {
289
393
  return '';
290
394
  }
291
395
  }
292
- export default InterruptPlugin();
293
- export { InterruptPlugin as Interrupt };
396
+ const tuiFn = async (api, _options, _meta) => {
397
+ console.log("[interrupt] TUI plugin initializing");
398
+ try {
399
+ api.keymap.registerLayer({
400
+ priority: 0,
401
+ commands: [
402
+ {
403
+ name: "interrupt.ptt",
404
+ title: "Walkie-Talkie (insert to record, press again to send)",
405
+ category: "Plugin",
406
+ run: async () => {
407
+ console.log("[interrupt] Command interrupt.ptt executed");
408
+ const route = api.route.current;
409
+ const sessionID = route.name === "session"
410
+ ? route.params?.sessionID
411
+ : undefined;
412
+ const directory = api.state.path.directory;
413
+ if (pttActive) {
414
+ pttActive = false;
415
+ await transcribeAndSend(sessionID, directory, api);
416
+ }
417
+ else {
418
+ pttActive = true;
419
+ if (sessionID) {
420
+ try {
421
+ await api.client.session.abort({ sessionID, directory });
422
+ }
423
+ catch { /* ignore */ }
424
+ }
425
+ pttStartRecording();
426
+ api.ui.toast({ variant: "info", title: "PTT", message: "Recording... (insert again to send)" });
427
+ }
428
+ },
429
+ },
430
+ ],
431
+ bindings: [{ key: "insert", cmd: "interrupt.ptt" }],
432
+ });
433
+ console.log("[interrupt] Layer registered successfully");
434
+ }
435
+ catch (err) {
436
+ console.error("[interrupt] Failed to register layer:", err);
437
+ }
438
+ api.lifecycle.onDispose(() => {
439
+ console.log("[interrupt] TUI plugin disposed");
440
+ if (recordingProcess) {
441
+ recordingProcess.kill("SIGTERM");
442
+ recordingProcess = null;
443
+ }
444
+ pttActive = false;
445
+ });
446
+ };
447
+ export default { id: "interrupt.walkie-talkie", tui: tuiFn };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "opencode-interrupt-plugin",
3
- "version": "0.4.21",
3
+ "version": "0.4.24",
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
8
  "exports": {
9
9
  ".": "./dist/index.js",
10
- "./tui": "./dist/tui.js"
10
+ "./tui": "./dist/index.js"
11
11
  },
12
12
  "files": [
13
13
  "dist/",
package/dist/tui.d.ts DELETED
@@ -1,6 +0,0 @@
1
- import type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
- declare const _default: {
3
- id: string;
4
- tui: TuiPlugin;
5
- };
6
- export default _default;
package/dist/tui.js DELETED
@@ -1,162 +0,0 @@
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
- let recordingProcess = null;
6
- let active = false;
7
- function pttStartRecording() {
8
- if (recordingProcess)
9
- return;
10
- recordingProcess = spawn("sox", [
11
- "-d", "--rate", "16000", "--channels", "1",
12
- "--encoding", "signed-integer", "--bits", "16",
13
- RECORDING_FILE,
14
- ], { stdio: ["ignore", "pipe", "pipe"] });
15
- recordingProcess.on("exit", () => { recordingProcess = null; });
16
- recordingProcess.on("error", () => { recordingProcess = null; });
17
- }
18
- function pttStopRecording() {
19
- if (!recordingProcess)
20
- return;
21
- recordingProcess.kill("SIGTERM");
22
- recordingProcess = null;
23
- }
24
- const TXT_FILE = "/tmp/interrupt-ptt.txt";
25
- async function transcribeLocal() {
26
- try {
27
- execSync("whisper --help", { stdio: "ignore", timeout: 3000 });
28
- }
29
- catch {
30
- return null;
31
- }
32
- try {
33
- unlinkSync(TXT_FILE);
34
- }
35
- catch { /* ignore */ }
36
- try {
37
- execSync(`whisper -f "${RECORDING_FILE}" -m "${WHISPER_MODEL}" -otxt -of /tmp/interrupt-ptt`, { stdio: "ignore", timeout: 30000 });
38
- }
39
- catch {
40
- return null;
41
- }
42
- if (existsSync(TXT_FILE)) {
43
- const text = readFileSync(TXT_FILE, "utf-8").trim();
44
- try {
45
- unlinkSync(TXT_FILE);
46
- }
47
- catch { /* ignore */ }
48
- return text || null;
49
- }
50
- return null;
51
- }
52
- async function transcribeAPI() {
53
- const apiKey = process.env.OPENAI_API_KEY;
54
- if (!apiKey || !existsSync(RECORDING_FILE))
55
- return null;
56
- const buffer = readFileSync(RECORDING_FILE);
57
- const form = new FormData();
58
- form.append("file", new Blob([buffer], { type: "audio/wav" }), "recording.wav");
59
- form.append("model", "whisper-1");
60
- try {
61
- const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
62
- method: "POST",
63
- headers: { Authorization: `Bearer ${apiKey}` },
64
- body: form,
65
- });
66
- if (!resp.ok)
67
- return null;
68
- const data = await resp.json();
69
- return data.text;
70
- }
71
- catch {
72
- return null;
73
- }
74
- }
75
- async function transcribeAndSend(sessionID, directory, api) {
76
- pttStopRecording();
77
- if (!existsSync(RECORDING_FILE)) {
78
- api.ui.toast({ variant: "warning", title: "PTT", message: "No audio captured" });
79
- return;
80
- }
81
- api.ui.toast({ variant: "info", title: "PTT", message: "Transcribing..." });
82
- let text = null;
83
- text = await transcribeLocal();
84
- if (!text)
85
- text = await transcribeAPI();
86
- if (!text) {
87
- api.ui.toast({
88
- variant: "error",
89
- title: "PTT",
90
- message: "Missing whisper — run scripts/install-whisper.sh or set OPENAI_API_KEY",
91
- });
92
- try {
93
- unlinkSync(RECORDING_FILE);
94
- }
95
- catch { /* ignore */ }
96
- return;
97
- }
98
- try {
99
- unlinkSync(RECORDING_FILE);
100
- }
101
- catch { /* ignore */ }
102
- if (!sessionID) {
103
- api.ui.toast({ variant: "warning", title: "PTT", message: "No active session" });
104
- return;
105
- }
106
- await api.client.session.prompt({
107
- sessionID,
108
- directory,
109
- parts: [{ type: "text", text }],
110
- });
111
- api.ui.toast({ variant: "success", title: "PTT", message: `Sent: "${text.slice(0, 60)}"` });
112
- }
113
- const tui = async (api, _options, _meta) => {
114
- console.log("[interrupt] TUI plugin loaded, keymap available:", !!api.keymap);
115
- try {
116
- const layer = api.keymap.registerLayer({
117
- priority: 1000,
118
- commands: [
119
- {
120
- name: "interrupt.ptt",
121
- title: "Walkie-Talkie (ctrl+alt+m to record, press again to send)",
122
- category: "Plugin",
123
- run: async () => {
124
- const route = api.route.current;
125
- const sessionID = route.name === "session"
126
- ? route.params?.sessionID
127
- : undefined;
128
- const directory = api.state.path.directory;
129
- if (active) {
130
- active = false;
131
- await transcribeAndSend(sessionID, directory, api);
132
- }
133
- else {
134
- active = true;
135
- if (sessionID) {
136
- try {
137
- await api.client.session.abort({ sessionID, directory });
138
- }
139
- catch { /* ignore */ }
140
- }
141
- pttStartRecording();
142
- api.ui.toast({ variant: "info", title: "PTT", message: "Recording... (ctrl+alt+m again to send)" });
143
- }
144
- },
145
- },
146
- ],
147
- bindings: [{ key: "ctrl+alt+m", cmd: "interrupt.ptt" }],
148
- });
149
- console.log("[interrupt] Layer registered, dispose fn type:", typeof layer);
150
- }
151
- catch (err) {
152
- console.error("[interrupt] registerLayer failed:", err);
153
- }
154
- api.lifecycle.onDispose(() => {
155
- if (recordingProcess) {
156
- recordingProcess.kill("SIGTERM");
157
- recordingProcess = null;
158
- }
159
- active = false;
160
- });
161
- };
162
- export default { id: "interrupt.walkie-talkie", tui };