opencode-interrupt-plugin 0.3.1
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/LICENSE +20 -0
- package/dist/audio/monitor.d.ts +15 -0
- package/dist/audio/monitor.js +116 -0
- package/dist/audio/overlap.d.ts +18 -0
- package/dist/audio/overlap.js +51 -0
- package/dist/audio/tts-tracker.d.ts +11 -0
- package/dist/audio/tts-tracker.js +37 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +53 -0
- package/dist/detector.d.ts +6 -0
- package/dist/detector.js +22 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +170 -0
- package/dist/injector.d.ts +11 -0
- package/dist/injector.js +45 -0
- package/dist/license/guard.d.ts +5 -0
- package/dist/license/guard.js +54 -0
- package/dist/license/storage.d.ts +12 -0
- package/dist/license/storage.js +23 -0
- package/dist/license/validator.d.ts +10 -0
- package/dist/license/validator.js +79 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.js +23 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
License Terms
|
|
2
|
+
=============
|
|
3
|
+
|
|
4
|
+
This software is not free software. Use is conditioned on either:
|
|
5
|
+
|
|
6
|
+
1. A valid paid license obtained via Polar.sh, granting full configuration access
|
|
7
|
+
and supporting ongoing development; OR
|
|
8
|
+
|
|
9
|
+
2. Free (unlicensed) usage with the following restrictions:
|
|
10
|
+
- Voice interruption detection is enabled with fixed default settings only
|
|
11
|
+
- No customization of microphone threshold, sensitivity, timing, or debug output
|
|
12
|
+
- The config interface (PluginConfig) may not be exercised beyond defaults
|
|
13
|
+
- No warranty or support is provided
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
18
|
+
|
|
19
|
+
Paid license keys are validated at runtime against the Polar.sh API.
|
|
20
|
+
License activation is limited to 3 machines per key.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
export declare class AudioMonitor extends EventEmitter {
|
|
3
|
+
private process;
|
|
4
|
+
private isRunning;
|
|
5
|
+
private silenceTimer;
|
|
6
|
+
private threshold;
|
|
7
|
+
private silenceMs;
|
|
8
|
+
constructor(threshold?: number, silenceMs?: number);
|
|
9
|
+
start(): void;
|
|
10
|
+
private tryFallbackMonitor;
|
|
11
|
+
stop(): void;
|
|
12
|
+
private handleRMS;
|
|
13
|
+
setThreshold(threshold: number): void;
|
|
14
|
+
isActive(): boolean;
|
|
15
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
export class AudioMonitor extends EventEmitter {
|
|
4
|
+
process = null;
|
|
5
|
+
isRunning = false;
|
|
6
|
+
silenceTimer = null;
|
|
7
|
+
threshold;
|
|
8
|
+
silenceMs;
|
|
9
|
+
constructor(threshold = 0.02, silenceMs = 300) {
|
|
10
|
+
super();
|
|
11
|
+
this.threshold = threshold;
|
|
12
|
+
this.silenceMs = silenceMs;
|
|
13
|
+
}
|
|
14
|
+
start() {
|
|
15
|
+
if (this.isRunning)
|
|
16
|
+
return;
|
|
17
|
+
this.process = spawn('sox', [
|
|
18
|
+
'-q',
|
|
19
|
+
'-d',
|
|
20
|
+
'-t', 'sox', '-',
|
|
21
|
+
'stat',
|
|
22
|
+
'-freq',
|
|
23
|
+
], {
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
});
|
|
26
|
+
let buffer = '';
|
|
27
|
+
this.process.stderr?.on('data', (chunk) => {
|
|
28
|
+
buffer += chunk.toString();
|
|
29
|
+
const lines = buffer.split('\n');
|
|
30
|
+
buffer = lines.pop() || '';
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const rms = parseRMSLine(line);
|
|
33
|
+
if (rms !== null) {
|
|
34
|
+
this.handleRMS(rms);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
this.process.on('error', () => {
|
|
39
|
+
this.tryFallbackMonitor();
|
|
40
|
+
});
|
|
41
|
+
this.process.on('exit', () => {
|
|
42
|
+
this.isRunning = false;
|
|
43
|
+
this.emit('stopped');
|
|
44
|
+
});
|
|
45
|
+
this.isRunning = true;
|
|
46
|
+
this.emit('started');
|
|
47
|
+
}
|
|
48
|
+
tryFallbackMonitor() {
|
|
49
|
+
const poll = () => {
|
|
50
|
+
if (!this.isRunning)
|
|
51
|
+
return;
|
|
52
|
+
const proc = spawn('sox', [
|
|
53
|
+
'-q', '-d',
|
|
54
|
+
'-t', 'sox', '-',
|
|
55
|
+
'trim', '0', '0.1',
|
|
56
|
+
'stat',
|
|
57
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
58
|
+
let out = '';
|
|
59
|
+
proc.stderr?.on('data', (d) => { out += d.toString(); });
|
|
60
|
+
proc.on('exit', () => {
|
|
61
|
+
const rms = parseRMSFromStat(out);
|
|
62
|
+
if (rms !== null)
|
|
63
|
+
this.handleRMS(rms);
|
|
64
|
+
setTimeout(poll, 100);
|
|
65
|
+
});
|
|
66
|
+
proc.on('error', () => {
|
|
67
|
+
this.emit('error', new Error('sox not found. Install it: brew install sox (macOS) or apt install sox (Linux)'));
|
|
68
|
+
this.isRunning = false;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
this.isRunning = true;
|
|
72
|
+
poll();
|
|
73
|
+
}
|
|
74
|
+
stop() {
|
|
75
|
+
this.isRunning = false;
|
|
76
|
+
if (this.process) {
|
|
77
|
+
this.process.kill('SIGTERM');
|
|
78
|
+
this.process = null;
|
|
79
|
+
}
|
|
80
|
+
if (this.silenceTimer) {
|
|
81
|
+
clearTimeout(this.silenceTimer);
|
|
82
|
+
this.silenceTimer = null;
|
|
83
|
+
}
|
|
84
|
+
this.emit('stopped');
|
|
85
|
+
}
|
|
86
|
+
handleRMS(rms) {
|
|
87
|
+
if (rms > this.threshold) {
|
|
88
|
+
this.emit('voice-detected', rms);
|
|
89
|
+
if (this.silenceTimer)
|
|
90
|
+
clearTimeout(this.silenceTimer);
|
|
91
|
+
this.silenceTimer = setTimeout(() => {
|
|
92
|
+
this.emit('silence');
|
|
93
|
+
}, this.silenceMs);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
setThreshold(threshold) {
|
|
97
|
+
this.threshold = threshold;
|
|
98
|
+
}
|
|
99
|
+
isActive() {
|
|
100
|
+
return this.isRunning;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function parseRMSLine(line) {
|
|
104
|
+
const match = line.match(/RMS amplitude:\s+([\d.]+)/);
|
|
105
|
+
if (match)
|
|
106
|
+
return parseFloat(match[1]);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function parseRMSFromStat(output) {
|
|
110
|
+
for (const line of output.split('\n')) {
|
|
111
|
+
const rms = parseRMSLine(line);
|
|
112
|
+
if (rms !== null)
|
|
113
|
+
return rms;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
export interface VoiceOverlapEvent {
|
|
3
|
+
partialTTSContent: string;
|
|
4
|
+
overlapTimestamp: number;
|
|
5
|
+
rmsLevel: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class VoiceOverlapDetector extends EventEmitter {
|
|
8
|
+
private monitor;
|
|
9
|
+
private active;
|
|
10
|
+
private cooldownUntil;
|
|
11
|
+
private readonly COOLDOWN_MS;
|
|
12
|
+
constructor(threshold?: number);
|
|
13
|
+
start(): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
updateThreshold(threshold: number): void;
|
|
16
|
+
private handleVoiceDetected;
|
|
17
|
+
isActive(): boolean;
|
|
18
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { AudioMonitor } from './monitor.js';
|
|
2
|
+
import { isTTSPlaying, getPartialTTSContent, onTTSEnd } from './tts-tracker.js';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
export class VoiceOverlapDetector extends EventEmitter {
|
|
5
|
+
monitor;
|
|
6
|
+
active = false;
|
|
7
|
+
cooldownUntil = 0;
|
|
8
|
+
COOLDOWN_MS = 3000;
|
|
9
|
+
constructor(threshold = 0.02) {
|
|
10
|
+
super();
|
|
11
|
+
this.monitor = new AudioMonitor(threshold);
|
|
12
|
+
this.monitor.on('voice-detected', (rms) => {
|
|
13
|
+
this.handleVoiceDetected(rms);
|
|
14
|
+
});
|
|
15
|
+
this.monitor.on('error', (err) => {
|
|
16
|
+
this.emit('monitor-error', err);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
start() {
|
|
20
|
+
if (this.active)
|
|
21
|
+
return;
|
|
22
|
+
this.active = true;
|
|
23
|
+
this.monitor.start();
|
|
24
|
+
}
|
|
25
|
+
stop() {
|
|
26
|
+
this.active = false;
|
|
27
|
+
this.monitor.stop();
|
|
28
|
+
}
|
|
29
|
+
updateThreshold(threshold) {
|
|
30
|
+
this.monitor.setThreshold(threshold);
|
|
31
|
+
}
|
|
32
|
+
handleVoiceDetected(rms) {
|
|
33
|
+
if (!isTTSPlaying())
|
|
34
|
+
return;
|
|
35
|
+
if (Date.now() < this.cooldownUntil)
|
|
36
|
+
return;
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
this.cooldownUntil = now + this.COOLDOWN_MS;
|
|
39
|
+
const partialContent = getPartialTTSContent(now);
|
|
40
|
+
onTTSEnd();
|
|
41
|
+
const event = {
|
|
42
|
+
partialTTSContent: partialContent,
|
|
43
|
+
overlapTimestamp: now,
|
|
44
|
+
rmsLevel: rms,
|
|
45
|
+
};
|
|
46
|
+
this.emit('voice-overlap', event);
|
|
47
|
+
}
|
|
48
|
+
isActive() {
|
|
49
|
+
return this.active;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface TTSState {
|
|
2
|
+
isPlaying: boolean;
|
|
3
|
+
startedAt: number;
|
|
4
|
+
content: string;
|
|
5
|
+
estimatedDurationMs: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function onTTSStart(content: string): void;
|
|
8
|
+
export declare function onTTSEnd(): void;
|
|
9
|
+
export declare function isTTSPlaying(): boolean;
|
|
10
|
+
export declare function isTTSTool(toolName: string): boolean;
|
|
11
|
+
export declare function getPartialTTSContent(elapsedMs: number): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const TTS_TOOL_NAMES = new Set(['speak', 'tts', 'text_to_speech', 'say']);
|
|
2
|
+
const ttsState = {
|
|
3
|
+
isPlaying: false,
|
|
4
|
+
startedAt: 0,
|
|
5
|
+
content: '',
|
|
6
|
+
estimatedDurationMs: 0,
|
|
7
|
+
};
|
|
8
|
+
function estimateDuration(text) {
|
|
9
|
+
const words = text.split(/\s+/).length;
|
|
10
|
+
const durationMs = (words / 2.5) * 1000;
|
|
11
|
+
return Math.max(500, Math.min(120000, durationMs));
|
|
12
|
+
}
|
|
13
|
+
export function onTTSStart(content) {
|
|
14
|
+
ttsState.isPlaying = true;
|
|
15
|
+
ttsState.startedAt = Date.now();
|
|
16
|
+
ttsState.content = content;
|
|
17
|
+
ttsState.estimatedDurationMs = estimateDuration(content);
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
if (ttsState.isPlaying && Date.now() - ttsState.startedAt > ttsState.estimatedDurationMs) {
|
|
20
|
+
ttsState.isPlaying = false;
|
|
21
|
+
}
|
|
22
|
+
}, ttsState.estimatedDurationMs + 2000);
|
|
23
|
+
}
|
|
24
|
+
export function onTTSEnd() {
|
|
25
|
+
ttsState.isPlaying = false;
|
|
26
|
+
}
|
|
27
|
+
export function isTTSPlaying() {
|
|
28
|
+
return ttsState.isPlaying;
|
|
29
|
+
}
|
|
30
|
+
export function isTTSTool(toolName) {
|
|
31
|
+
return TTS_TOOL_NAMES.has(toolName.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
export function getPartialTTSContent(elapsedMs) {
|
|
34
|
+
const progress = Math.min(elapsedMs / ttsState.estimatedDurationMs, 0.95);
|
|
35
|
+
const charIndex = Math.floor(ttsState.content.length * progress);
|
|
36
|
+
return ttsState.content.slice(0, charIndex) + '...[interrupted]';
|
|
37
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface PluginConfig {
|
|
2
|
+
licenseKey?: string;
|
|
3
|
+
micThreshold?: number;
|
|
4
|
+
sensitivity?: 'low' | 'medium' | 'high';
|
|
5
|
+
timingWindowMs?: number;
|
|
6
|
+
maxCorrectionLength?: number;
|
|
7
|
+
minResponseLength?: number;
|
|
8
|
+
voiceDetection?: boolean;
|
|
9
|
+
debug?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ResolvedConfig {
|
|
12
|
+
licenseKey?: string;
|
|
13
|
+
isLicensed: boolean;
|
|
14
|
+
micThreshold: number;
|
|
15
|
+
sensitivity: string;
|
|
16
|
+
timingWindowMs: number;
|
|
17
|
+
maxCorrectionLength: number;
|
|
18
|
+
minResponseLength: number;
|
|
19
|
+
voiceDetection: boolean;
|
|
20
|
+
voiceMode: string;
|
|
21
|
+
debug: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare const FREE_DEFAULTS: ResolvedConfig;
|
|
24
|
+
export declare const SENSITIVITY_PRESETS: Record<string, Partial<ResolvedConfig>>;
|
|
25
|
+
export declare function resolveConfig(userConfig: PluginConfig, isLicensed: boolean): ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const FREE_DEFAULTS = {
|
|
2
|
+
isLicensed: false,
|
|
3
|
+
micThreshold: 0.02,
|
|
4
|
+
sensitivity: 'medium',
|
|
5
|
+
timingWindowMs: 30000,
|
|
6
|
+
maxCorrectionLength: 1000,
|
|
7
|
+
minResponseLength: 200,
|
|
8
|
+
voiceDetection: true,
|
|
9
|
+
voiceMode: 'enabled',
|
|
10
|
+
debug: false,
|
|
11
|
+
};
|
|
12
|
+
export const SENSITIVITY_PRESETS = {
|
|
13
|
+
low: {
|
|
14
|
+
timingWindowMs: 15000,
|
|
15
|
+
maxCorrectionLength: 500,
|
|
16
|
+
minResponseLength: 100,
|
|
17
|
+
},
|
|
18
|
+
medium: {
|
|
19
|
+
timingWindowMs: 30000,
|
|
20
|
+
maxCorrectionLength: 1000,
|
|
21
|
+
minResponseLength: 200,
|
|
22
|
+
},
|
|
23
|
+
high: {
|
|
24
|
+
timingWindowMs: 60000,
|
|
25
|
+
maxCorrectionLength: 2000,
|
|
26
|
+
minResponseLength: 400,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
export function resolveConfig(userConfig, isLicensed) {
|
|
30
|
+
if (!isLicensed) {
|
|
31
|
+
return { ...FREE_DEFAULTS };
|
|
32
|
+
}
|
|
33
|
+
const base = {
|
|
34
|
+
...FREE_DEFAULTS,
|
|
35
|
+
isLicensed: true,
|
|
36
|
+
micThreshold: userConfig.micThreshold ?? FREE_DEFAULTS.micThreshold,
|
|
37
|
+
sensitivity: userConfig.sensitivity ?? FREE_DEFAULTS.sensitivity,
|
|
38
|
+
timingWindowMs: userConfig.timingWindowMs ?? FREE_DEFAULTS.timingWindowMs,
|
|
39
|
+
maxCorrectionLength: userConfig.maxCorrectionLength ?? FREE_DEFAULTS.maxCorrectionLength,
|
|
40
|
+
minResponseLength: userConfig.minResponseLength ?? FREE_DEFAULTS.minResponseLength,
|
|
41
|
+
voiceDetection: userConfig.voiceDetection ?? FREE_DEFAULTS.voiceDetection,
|
|
42
|
+
debug: userConfig.debug ?? FREE_DEFAULTS.debug,
|
|
43
|
+
licenseKey: userConfig.licenseKey,
|
|
44
|
+
};
|
|
45
|
+
const preset = SENSITIVITY_PRESETS[base.sensitivity];
|
|
46
|
+
if (!userConfig.timingWindowMs)
|
|
47
|
+
base.timingWindowMs = preset.timingWindowMs ?? base.timingWindowMs;
|
|
48
|
+
if (!userConfig.maxCorrectionLength)
|
|
49
|
+
base.maxCorrectionLength = preset.maxCorrectionLength ?? base.maxCorrectionLength;
|
|
50
|
+
if (!userConfig.minResponseLength)
|
|
51
|
+
base.minResponseLength = preset.minResponseLength ?? base.minResponseLength;
|
|
52
|
+
return base;
|
|
53
|
+
}
|
package/dist/detector.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const TEXT_INTERRUPT_PATTERNS = [
|
|
2
|
+
/\b(stop|wait|hold on|hold up)\b/i,
|
|
3
|
+
/\b(actually|never mind|forget it|scratch that|disregard)\b/i,
|
|
4
|
+
/\b(interrupt|break|abort|cancel)\b/i,
|
|
5
|
+
];
|
|
6
|
+
export function detectTextInterruption(userMessage, sessionState) {
|
|
7
|
+
if (sessionState.wasInterrupted && sessionState.awaitingCorrection) {
|
|
8
|
+
return {
|
|
9
|
+
detected: true,
|
|
10
|
+
reason: `Session marked as interrupted (source: ${sessionState.interruptSource || 'unknown'})`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
for (const pattern of TEXT_INTERRUPT_PATTERNS) {
|
|
14
|
+
if (pattern.test(userMessage)) {
|
|
15
|
+
return {
|
|
16
|
+
detected: true,
|
|
17
|
+
reason: `Text matched interruption pattern: ${pattern.source}`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { detected: false, reason: '' };
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
import { type PluginConfig } from './config.js';
|
|
3
|
+
export declare const InterruptPlugin: (userConfig?: PluginConfig) => Plugin;
|
|
4
|
+
declare const _default: Plugin;
|
|
5
|
+
export default _default;
|
|
6
|
+
export { InterruptPlugin as Interrupt };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { resolveConfig } from './config.js';
|
|
2
|
+
import { checkLicense } from './license/guard.js';
|
|
3
|
+
import { getSessionState, updateSessionState, clearSessionState, } from './store.js';
|
|
4
|
+
import { prepareInjection } from './injector.js';
|
|
5
|
+
import { onTTSStart, onTTSEnd, isTTSTool } from './audio/tts-tracker.js';
|
|
6
|
+
import { VoiceOverlapDetector } from './audio/overlap.js';
|
|
7
|
+
import { detectTextInterruption } from './detector.js';
|
|
8
|
+
let activeSessionId = null;
|
|
9
|
+
export const InterruptPlugin = (userConfig = {}) => {
|
|
10
|
+
return async ({ client }) => {
|
|
11
|
+
const licenseResult = await checkLicense(userConfig.licenseKey);
|
|
12
|
+
const config = resolveConfig(userConfig, licenseResult.allowed);
|
|
13
|
+
if (config.debug) {
|
|
14
|
+
console.log(`[interrupt] Plugin loaded (${config.isLicensed ? 'licensed' : 'free'} mode)`);
|
|
15
|
+
}
|
|
16
|
+
if (!config.isLicensed) {
|
|
17
|
+
console.log('\n[interrupt] Free mode — voice interruption enabled with default settings.\n' +
|
|
18
|
+
' Purchase a license at camaramagic.com to customize sensitivity, mic threshold, and more.\n');
|
|
19
|
+
}
|
|
20
|
+
const overlapDetector = new VoiceOverlapDetector(config.micThreshold);
|
|
21
|
+
overlapDetector.on('voice-overlap', (event) => {
|
|
22
|
+
if (!activeSessionId)
|
|
23
|
+
return;
|
|
24
|
+
if (config.debug) {
|
|
25
|
+
console.log(`[interrupt] Voice overlap (RMS: ${event.rmsLevel.toFixed(4)})`);
|
|
26
|
+
}
|
|
27
|
+
updateSessionState(activeSessionId, {
|
|
28
|
+
wasInterrupted: true,
|
|
29
|
+
partialContentAtInterrupt: event.partialTTSContent,
|
|
30
|
+
interruptTimestamp: event.overlapTimestamp,
|
|
31
|
+
awaitingCorrection: true,
|
|
32
|
+
interruptSource: 'voice',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
overlapDetector.on('monitor-error', (err) => {
|
|
36
|
+
if (config.debug) {
|
|
37
|
+
console.warn(`[interrupt] Audio monitor error: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
overlapDetector.start();
|
|
41
|
+
process.on('exit', () => overlapDetector.stop());
|
|
42
|
+
return {
|
|
43
|
+
event: async ({ event }) => {
|
|
44
|
+
const evt = event;
|
|
45
|
+
const sessionId = evt.session_id || evt.properties?.info?.id;
|
|
46
|
+
if (evt.type === 'session.created' && sessionId) {
|
|
47
|
+
activeSessionId = sessionId;
|
|
48
|
+
getSessionState(sessionId);
|
|
49
|
+
}
|
|
50
|
+
if (evt.type === 'session.deleted' && sessionId) {
|
|
51
|
+
clearSessionState(sessionId);
|
|
52
|
+
if (activeSessionId === sessionId)
|
|
53
|
+
activeSessionId = null;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
'chat.message': async (input, output) => {
|
|
57
|
+
const sessionId = input.sessionID;
|
|
58
|
+
if (!sessionId)
|
|
59
|
+
return;
|
|
60
|
+
const msg = output.message;
|
|
61
|
+
const parts = output.parts;
|
|
62
|
+
if (!msg || !msg.role)
|
|
63
|
+
return;
|
|
64
|
+
if (msg.role === 'assistant') {
|
|
65
|
+
const content = extractText(parts);
|
|
66
|
+
updateSessionState(sessionId, {
|
|
67
|
+
lastAssistantContent: content,
|
|
68
|
+
lastAssistantTimestamp: Date.now(),
|
|
69
|
+
wasInterrupted: false,
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (msg.role === 'user' && !overlapDetector.isActive()) {
|
|
74
|
+
const state = getSessionState(sessionId);
|
|
75
|
+
const userText = extractText(parts);
|
|
76
|
+
const signal = detectTextInterruption(userText, state);
|
|
77
|
+
if (signal.detected) {
|
|
78
|
+
const partialContent = state.lastAssistantContent;
|
|
79
|
+
updateSessionState(sessionId, {
|
|
80
|
+
wasInterrupted: true,
|
|
81
|
+
partialContentAtInterrupt: partialContent,
|
|
82
|
+
interruptTimestamp: Date.now(),
|
|
83
|
+
awaitingCorrection: true,
|
|
84
|
+
interruptSource: 'text',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
90
|
+
const sessionId = input.sessionID;
|
|
91
|
+
if (!sessionId)
|
|
92
|
+
return;
|
|
93
|
+
activeSessionId = sessionId;
|
|
94
|
+
const sessionState = getSessionState(sessionId);
|
|
95
|
+
const userMessage = extractUserMessage(input);
|
|
96
|
+
if (!userMessage)
|
|
97
|
+
return;
|
|
98
|
+
const currentSystem = output.system?.join('\n') || '';
|
|
99
|
+
const { systemPrompt, result } = prepareInjection(userMessage, currentSystem, sessionState, config);
|
|
100
|
+
if (result.injected) {
|
|
101
|
+
output.system = [systemPrompt];
|
|
102
|
+
if (config.debug) {
|
|
103
|
+
console.log(`[interrupt] ${result.reason}`);
|
|
104
|
+
}
|
|
105
|
+
updateSessionState(sessionId, {
|
|
106
|
+
wasInterrupted: false,
|
|
107
|
+
partialContentAtInterrupt: '',
|
|
108
|
+
awaitingCorrection: false,
|
|
109
|
+
interruptSource: undefined,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
'tool.execute.before': async (input, output) => {
|
|
114
|
+
const toolName = input.tool || '';
|
|
115
|
+
const args = output?.args || {};
|
|
116
|
+
if (isTTSTool(toolName)) {
|
|
117
|
+
const text = args.text || args.content || '';
|
|
118
|
+
onTTSStart(text);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
'tool.execute.after': async (input, output) => {
|
|
122
|
+
const toolName = input.tool?.name || '';
|
|
123
|
+
if (isTTSTool(toolName)) {
|
|
124
|
+
onTTSEnd();
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
function extractUserMessage(input) {
|
|
131
|
+
try {
|
|
132
|
+
const messages = input.messages || [];
|
|
133
|
+
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
|
134
|
+
if (!lastUser)
|
|
135
|
+
return '';
|
|
136
|
+
return extractContent(lastUser);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function extractContent(obj) {
|
|
143
|
+
try {
|
|
144
|
+
const content = obj.content || obj.message?.content;
|
|
145
|
+
if (typeof content === 'string')
|
|
146
|
+
return content;
|
|
147
|
+
if (Array.isArray(content)) {
|
|
148
|
+
return content.filter((c) => c.type === 'text').map((c) => c.text).join(' ');
|
|
149
|
+
}
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function extractText(parts) {
|
|
157
|
+
try {
|
|
158
|
+
if (!Array.isArray(parts))
|
|
159
|
+
return '';
|
|
160
|
+
return parts
|
|
161
|
+
.filter((p) => p.type === 'text')
|
|
162
|
+
.map((p) => p.text || '')
|
|
163
|
+
.join(' ');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export default InterruptPlugin();
|
|
170
|
+
export { InterruptPlugin as Interrupt };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SessionState } from './store.js';
|
|
2
|
+
import type { ResolvedConfig } from './config.js';
|
|
3
|
+
export interface InjectionResult {
|
|
4
|
+
injected: boolean;
|
|
5
|
+
reason: string;
|
|
6
|
+
}
|
|
7
|
+
export interface InjectionOutput {
|
|
8
|
+
systemPrompt: string;
|
|
9
|
+
result: InjectionResult;
|
|
10
|
+
}
|
|
11
|
+
export declare function prepareInjection(userMessage: string, currentSystem: string, sessionState: SessionState, config: ResolvedConfig): InjectionOutput;
|
package/dist/injector.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const INTERRUPT_TEMPLATE = `
|
|
2
|
+
[INTERRUPTION CONTEXT]
|
|
3
|
+
The user interrupted the previous response.
|
|
4
|
+
{reason}
|
|
5
|
+
The assistant was saying:
|
|
6
|
+
"{partialContent}"
|
|
7
|
+
The user will now correct or clarify. Respond to the correction, not to a blank slate.
|
|
8
|
+
Do not repeat what was interrupted. Acknowledge the interruption in one sentence at most, then address the user's new input directly.
|
|
9
|
+
`;
|
|
10
|
+
export function prepareInjection(userMessage, currentSystem, sessionState, config) {
|
|
11
|
+
if (!sessionState.wasInterrupted || !sessionState.awaitingCorrection) {
|
|
12
|
+
return {
|
|
13
|
+
systemPrompt: currentSystem,
|
|
14
|
+
result: { injected: false, reason: 'No interruption to inject' },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const elapsed = Date.now() - sessionState.interruptTimestamp;
|
|
18
|
+
if (elapsed > config.timingWindowMs) {
|
|
19
|
+
return {
|
|
20
|
+
systemPrompt: currentSystem,
|
|
21
|
+
result: { injected: false, reason: `Interruption window expired (${elapsed}ms > ${config.timingWindowMs}ms)` },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const partial = sessionState.partialContentAtInterrupt || '(unknown content)';
|
|
25
|
+
const source = sessionState.interruptSource || 'unknown';
|
|
26
|
+
const sourceReason = getReasonForSource(source);
|
|
27
|
+
const contextBlock = INTERRUPT_TEMPLATE
|
|
28
|
+
.replace('{reason}', sourceReason)
|
|
29
|
+
.replace('{partialContent}', partial);
|
|
30
|
+
const separator = currentSystem.trim() ? '\n\n---\n' : '';
|
|
31
|
+
return {
|
|
32
|
+
systemPrompt: currentSystem + separator + contextBlock,
|
|
33
|
+
result: { injected: true, reason: `Injected ${source} interruption context` },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function getReasonForSource(source) {
|
|
37
|
+
switch (source) {
|
|
38
|
+
case 'voice':
|
|
39
|
+
return 'The user spoke over the TTS output.';
|
|
40
|
+
case 'text':
|
|
41
|
+
return 'The user interrupted via text message.';
|
|
42
|
+
default:
|
|
43
|
+
return 'The assistant output was interrupted.';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import { readLicense, writeLicense, clearLicense, } from './storage.js';
|
|
3
|
+
import { activateLicense, validateLicense, getOfflineGraceDate, isWithinGracePeriod, } from './validator.js';
|
|
4
|
+
function getMachineLabel() {
|
|
5
|
+
return `${os.hostname()}-${os.platform()}`;
|
|
6
|
+
}
|
|
7
|
+
export async function checkLicense(providedKey) {
|
|
8
|
+
if (!providedKey) {
|
|
9
|
+
return { allowed: false };
|
|
10
|
+
}
|
|
11
|
+
const stored = await readLicense();
|
|
12
|
+
if (!stored) {
|
|
13
|
+
const result = await activateLicense(providedKey, getMachineLabel());
|
|
14
|
+
if (!result.valid) {
|
|
15
|
+
console.log(`\n[interrupt] License activation failed: ${result.reason}\n`);
|
|
16
|
+
return { allowed: false };
|
|
17
|
+
}
|
|
18
|
+
await writeLicense({
|
|
19
|
+
key: providedKey,
|
|
20
|
+
activationId: result.activationId,
|
|
21
|
+
activatedAt: new Date().toISOString(),
|
|
22
|
+
lastValidatedAt: new Date().toISOString(),
|
|
23
|
+
status: 'active',
|
|
24
|
+
email: result.email,
|
|
25
|
+
offlineGraceUntil: getOfflineGraceDate(),
|
|
26
|
+
});
|
|
27
|
+
console.log(`\n[interrupt] License activated${result.email ? ` for ${result.email}` : ''}. Premium features enabled.\n`);
|
|
28
|
+
return { allowed: true, email: result.email };
|
|
29
|
+
}
|
|
30
|
+
if (providedKey !== stored.key) {
|
|
31
|
+
await clearLicense();
|
|
32
|
+
return checkLicense(providedKey);
|
|
33
|
+
}
|
|
34
|
+
const result = await validateLicense(stored.key, stored.activationId);
|
|
35
|
+
if (result.valid) {
|
|
36
|
+
await writeLicense({
|
|
37
|
+
...stored,
|
|
38
|
+
lastValidatedAt: new Date().toISOString(),
|
|
39
|
+
status: 'active',
|
|
40
|
+
offlineGraceUntil: getOfflineGraceDate(),
|
|
41
|
+
});
|
|
42
|
+
return { allowed: true };
|
|
43
|
+
}
|
|
44
|
+
if (result.reason === 'OFFLINE') {
|
|
45
|
+
if (isWithinGracePeriod(stored.offlineGraceUntil)) {
|
|
46
|
+
console.log(`\n[interrupt] Offline — using cached license (grace period active).\n`);
|
|
47
|
+
return { allowed: true };
|
|
48
|
+
}
|
|
49
|
+
console.log(`\n[interrupt] License validation failed: offline for more than 7 days. Connect to the internet to revalidate.\n`);
|
|
50
|
+
return { allowed: false };
|
|
51
|
+
}
|
|
52
|
+
console.log(`\n[interrupt] License invalid: ${result.reason}\n`);
|
|
53
|
+
return { allowed: false };
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface LicenseData {
|
|
2
|
+
key: string;
|
|
3
|
+
activationId: string;
|
|
4
|
+
activatedAt: string;
|
|
5
|
+
lastValidatedAt: string;
|
|
6
|
+
status: 'active' | 'invalid' | 'expired';
|
|
7
|
+
email?: string;
|
|
8
|
+
offlineGraceUntil: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function readLicense(): Promise<LicenseData | null>;
|
|
11
|
+
export declare function writeLicense(data: LicenseData): Promise<void>;
|
|
12
|
+
export declare function clearLicense(): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const LICENSE_PATH = path.join(os.homedir(), '.config', 'opencode', 'interrupt-voice.license.json');
|
|
5
|
+
export async function readLicense() {
|
|
6
|
+
try {
|
|
7
|
+
const raw = await fs.promises.readFile(LICENSE_PATH, 'utf8');
|
|
8
|
+
return JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function writeLicense(data) {
|
|
15
|
+
await fs.promises.mkdir(path.dirname(LICENSE_PATH), { recursive: true });
|
|
16
|
+
await fs.promises.writeFile(LICENSE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
17
|
+
}
|
|
18
|
+
export async function clearLicense() {
|
|
19
|
+
try {
|
|
20
|
+
await fs.promises.unlink(LICENSE_PATH);
|
|
21
|
+
}
|
|
22
|
+
catch { /* already gone */ }
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ValidationResult {
|
|
2
|
+
valid: boolean;
|
|
3
|
+
reason?: string;
|
|
4
|
+
email?: string;
|
|
5
|
+
activationId?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function activateLicense(key: string, machineLabel: string): Promise<ValidationResult>;
|
|
8
|
+
export declare function validateLicense(key: string, activationId: string): Promise<ValidationResult>;
|
|
9
|
+
export declare function getOfflineGraceDate(): string;
|
|
10
|
+
export declare function isWithinGracePeriod(offlineGraceUntil: string): boolean;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const POLAR_ORG_ID = '166f4429-84b8-4e41-9c74-eff1b8724873';
|
|
2
|
+
const POLAR_VALIDATE_URL = 'https://api.polar.sh/v1/customer-portal/license-keys/validate';
|
|
3
|
+
const POLAR_ACTIVATE_URL = 'https://api.polar.sh/v1/customer-portal/license-keys/activate';
|
|
4
|
+
const OFFLINE_GRACE_DAYS = 7;
|
|
5
|
+
export async function activateLicense(key, machineLabel) {
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetch(POLAR_ACTIVATE_URL, {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
body: JSON.stringify({
|
|
11
|
+
key,
|
|
12
|
+
organization_id: POLAR_ORG_ID,
|
|
13
|
+
label: machineLabel,
|
|
14
|
+
conditions: { major_version: 1 },
|
|
15
|
+
}),
|
|
16
|
+
signal: AbortSignal.timeout(8000),
|
|
17
|
+
});
|
|
18
|
+
if (response.status === 200) {
|
|
19
|
+
const data = await response.json();
|
|
20
|
+
return {
|
|
21
|
+
valid: true,
|
|
22
|
+
activationId: data.id,
|
|
23
|
+
email: data.license_key?.customer?.email,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (response.status === 403) {
|
|
27
|
+
return { valid: false, reason: 'License key activation limit reached (max 3 machines). Deactivate another machine first.' };
|
|
28
|
+
}
|
|
29
|
+
if (response.status === 404) {
|
|
30
|
+
return { valid: false, reason: 'License key not found. Check your key and try again.' };
|
|
31
|
+
}
|
|
32
|
+
return { valid: false, reason: `Activation failed (status ${response.status})` };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (err.name === 'TimeoutError') {
|
|
36
|
+
return { valid: false, reason: 'Network timeout during activation. Check your internet connection.' };
|
|
37
|
+
}
|
|
38
|
+
return { valid: false, reason: `Activation error: ${err.message}` };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function validateLicense(key, activationId) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(POLAR_VALIDATE_URL, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
key,
|
|
48
|
+
organization_id: POLAR_ORG_ID,
|
|
49
|
+
activation_id: activationId,
|
|
50
|
+
conditions: { major_version: 1 },
|
|
51
|
+
}),
|
|
52
|
+
signal: AbortSignal.timeout(5000),
|
|
53
|
+
});
|
|
54
|
+
if (response.status === 200) {
|
|
55
|
+
const data = await response.json();
|
|
56
|
+
const isValid = data.status === 'granted';
|
|
57
|
+
return {
|
|
58
|
+
valid: isValid,
|
|
59
|
+
reason: isValid ? undefined : `License status: ${data.status}`,
|
|
60
|
+
email: data.customer?.email,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (response.status === 404) {
|
|
64
|
+
return { valid: false, reason: 'License key revoked or not found.' };
|
|
65
|
+
}
|
|
66
|
+
return { valid: false, reason: `Validation failed (status ${response.status})` };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { valid: false, reason: 'OFFLINE' };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function getOfflineGraceDate() {
|
|
73
|
+
const d = new Date();
|
|
74
|
+
d.setDate(d.getDate() + OFFLINE_GRACE_DAYS);
|
|
75
|
+
return d.toISOString();
|
|
76
|
+
}
|
|
77
|
+
export function isWithinGracePeriod(offlineGraceUntil) {
|
|
78
|
+
return new Date(offlineGraceUntil) > new Date();
|
|
79
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SessionState {
|
|
2
|
+
lastAssistantContent: string;
|
|
3
|
+
lastAssistantTimestamp: number;
|
|
4
|
+
wasInterrupted: boolean;
|
|
5
|
+
partialContentAtInterrupt: string;
|
|
6
|
+
interruptTimestamp: number;
|
|
7
|
+
awaitingCorrection: boolean;
|
|
8
|
+
interruptSource?: 'voice' | 'text';
|
|
9
|
+
}
|
|
10
|
+
export declare function getSessionState(sessionId: string): SessionState;
|
|
11
|
+
export declare function updateSessionState(sessionId: string, updates: Partial<SessionState>): SessionState;
|
|
12
|
+
export declare function clearSessionState(sessionId: string): void;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const sessions = new Map();
|
|
2
|
+
export function getSessionState(sessionId) {
|
|
3
|
+
if (!sessions.has(sessionId)) {
|
|
4
|
+
sessions.set(sessionId, {
|
|
5
|
+
lastAssistantContent: '',
|
|
6
|
+
lastAssistantTimestamp: 0,
|
|
7
|
+
wasInterrupted: false,
|
|
8
|
+
partialContentAtInterrupt: '',
|
|
9
|
+
interruptTimestamp: 0,
|
|
10
|
+
awaitingCorrection: false,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return sessions.get(sessionId);
|
|
14
|
+
}
|
|
15
|
+
export function updateSessionState(sessionId, updates) {
|
|
16
|
+
const state = getSessionState(sessionId);
|
|
17
|
+
Object.assign(state, updates);
|
|
18
|
+
sessions.set(sessionId, state);
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
export function clearSessionState(sessionId) {
|
|
22
|
+
sessions.delete(sessionId);
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-interrupt-plugin",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Voice interruption for OpenCode. Detects when you speak over TTS output and redirects the AI mid-response.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"dev": "tsc --watch",
|
|
14
|
+
"prepublishOnly": "tsc"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@opencode-ai/plugin": "latest"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"typescript": "^5.0.0"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"opencode",
|
|
25
|
+
"opencode-plugin",
|
|
26
|
+
"interruption",
|
|
27
|
+
"voice",
|
|
28
|
+
"ai",
|
|
29
|
+
"cli",
|
|
30
|
+
"tts"
|
|
31
|
+
],
|
|
32
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
33
|
+
}
|