opencode-interrupt-plugin 0.4.35 → 0.4.36

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.js CHANGED
@@ -2,6 +2,7 @@ import { resolveConfig } from './config.js';
2
2
  import { checkLicense } from './license/guard.js';
3
3
  import { debug } from './log.js';
4
4
  import { processTranscription } from './clean-text.js';
5
+ import { detectLanguage, getVoiceForLanguage } from './language.js';
5
6
  import { getSessionState, updateSessionState, clearSessionState, } from './store.js';
6
7
  import { prepareInjection } from './injector.js';
7
8
  import { onTTSStart, onTTSEnd, isTTSTool } from './audio/tts-tracker.js';
@@ -9,9 +10,71 @@ import { VoiceOverlapDetector } from './audio/overlap.js';
9
10
  import { detectTextInterruption } from './detector.js';
10
11
  import { TTSStreamer } from './tts/index.js';
11
12
  import { spawn, execSync } from "node:child_process";
12
- import { readFileSync, existsSync, unlinkSync } from "node:fs";
13
+ import { readFileSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
13
15
  let activeSessionId = null;
14
16
  let pendingInterrupt = null;
17
+ let isLicensed = false;
18
+ const MAX_FREE_INTERRUPTS_PER_DAY = 20;
19
+ const INTERRUPT_COUNT_DIR = join(process.env.HOME || '/tmp', '.cache', 'opencode');
20
+ const INTERRUPT_COUNT_FILE = join(INTERRUPT_COUNT_DIR, 'interrupt-count.json');
21
+ // simple checksum to detect casual tampering — not cryptographic
22
+ const COUNT_CK_KEY = 'interrupt-cap-v1';
23
+ function checksumCount(data) {
24
+ let hash = 0;
25
+ for (let i = 0; i < data.length; i++) {
26
+ hash = ((hash << 5) - hash) + data.charCodeAt(i);
27
+ hash |= 0;
28
+ }
29
+ const keyHash = COUNT_CK_KEY.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0);
30
+ return String((hash ^ keyHash) >>> 0);
31
+ }
32
+ function getInterruptCount() {
33
+ try {
34
+ const raw = readFileSync(INTERRUPT_COUNT_FILE, 'utf-8');
35
+ const parsed = JSON.parse(raw);
36
+ if (!parsed.date || typeof parsed.count !== 'number' || !parsed.ck)
37
+ return { date: '', count: 0 };
38
+ const expected = checksumCount(`${parsed.date}:${parsed.count}`);
39
+ if (parsed.ck !== expected)
40
+ return { date: '', count: 0 };
41
+ return { date: parsed.date, count: parsed.count };
42
+ }
43
+ catch {
44
+ return { date: '', count: 0 };
45
+ }
46
+ }
47
+ function writeInterruptCount(date, count) {
48
+ try {
49
+ mkdirSync(INTERRUPT_COUNT_DIR, { recursive: true });
50
+ }
51
+ catch { /* ignore */ }
52
+ const ck = checksumCount(`${date}:${count}`);
53
+ writeFileSync(INTERRUPT_COUNT_FILE, JSON.stringify({ date, count, ck }));
54
+ }
55
+ function checkInterruptCap() {
56
+ if (isLicensed)
57
+ return true;
58
+ const today = new Date().toISOString().slice(0, 10);
59
+ const record = getInterruptCount();
60
+ if (record.date !== today) {
61
+ writeInterruptCount(today, 0);
62
+ return true;
63
+ }
64
+ return record.count < MAX_FREE_INTERRUPTS_PER_DAY;
65
+ }
66
+ function incrementInterruptCount() {
67
+ if (isLicensed)
68
+ return;
69
+ const today = new Date().toISOString().slice(0, 10);
70
+ const record = getInterruptCount();
71
+ if (record.date !== today) {
72
+ writeInterruptCount(today, 1);
73
+ }
74
+ else {
75
+ writeInterruptCount(today, record.count + 1);
76
+ }
77
+ }
15
78
  const RECORDING_FILE = "/tmp/interrupt-ptt.wav";
16
79
  let recordingProcess = null;
17
80
  let pttActive = false;
@@ -127,6 +190,19 @@ async function transcribeAndSend(sessionID, directory, api, modelPath) {
127
190
  api.ui.toast({ variant: "warning", title: "PTT", message: "⚠️ Open a session first, then type /ptt" });
128
191
  return;
129
192
  }
193
+ if (!checkInterruptCap()) {
194
+ api.ui.toast({ variant: "warning", title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts" });
195
+ try {
196
+ unlinkSync(RECORDING_FILE);
197
+ }
198
+ catch { /* ignore */ }
199
+ return;
200
+ }
201
+ const state = getSessionState(sessionID);
202
+ if (!state.interruptCounted)
203
+ incrementInterruptCount();
204
+ else
205
+ updateSessionState(sessionID, { interruptCounted: false });
130
206
  api.ui.toast({ variant: "info", title: "PTT", message: polished ? "✨ Sending polished transcript..." : "✉️ Sending transcript..." });
131
207
  await api.client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: clean }] });
132
208
  const preview = clean.length > 80 ? clean.slice(0, 77) + "..." : clean;
@@ -176,6 +252,19 @@ async function transcribeAndSendV1(sessionID, client, modelPath) {
176
252
  await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Open a session first, then type /ptt", variant: "warning", duration: 5000 } });
177
253
  return;
178
254
  }
255
+ if (!checkInterruptCap()) {
256
+ await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts", variant: "warning", duration: 6000 } });
257
+ try {
258
+ unlinkSync(RECORDING_FILE);
259
+ }
260
+ catch { /* ignore */ }
261
+ return;
262
+ }
263
+ const state = getSessionState(sessionID);
264
+ if (!state.interruptCounted)
265
+ incrementInterruptCount();
266
+ else
267
+ updateSessionState(sessionID, { interruptCounted: false });
179
268
  await client.tui.showToast({ body: { title: "PTT", message: polished ? "✨ Sending polished transcript..." : "✉️ Sending transcript...", variant: "info" } });
180
269
  await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: clean }] } });
181
270
  const preview = clean.length > 80 ? clean.slice(0, 77) + "..." : clean;
@@ -190,13 +279,14 @@ const TTS_COMMANDS = [
190
279
  export const InterruptPlugin = (userConfig = {}) => {
191
280
  return async ({ client }) => {
192
281
  const licenseResult = await checkLicense(userConfig.licenseKey);
282
+ isLicensed = licenseResult.allowed;
193
283
  const config = resolveConfig(userConfig, licenseResult.allowed);
194
284
  if (config.debug) {
195
- console.log(`[interrupt] Plugin loaded (${config.isLicensed ? 'licensed' : 'free'} mode)`);
285
+ console.log(`[interrupt] Plugin loaded (${isLicensed ? 'licensed' : 'free'} mode)`);
196
286
  }
197
- if (!config.isLicensed) {
198
- console.log('\n[interrupt] Free mode — voice interruption enabled with default settings.\n' +
199
- ' Purchase a license at camaramagic.com to customize sensitivity, mic threshold, and more.\n');
287
+ if (!isLicensed) {
288
+ console.log('\n[interrupt] Free mode — 20 interrupts/day, base whisper model only.\n' +
289
+ ' Purchase a license at camaramagic.com for unlimited interrupts, model selection, TTS voices, and auto-language detection.\n');
200
290
  }
201
291
  if (config.tts) {
202
292
  if (config.debug) {
@@ -229,13 +319,19 @@ export const InterruptPlugin = (userConfig = {}) => {
229
319
  debug(`[interrupt] Voice overlap event buffered (no active session)`);
230
320
  return;
231
321
  }
322
+ if (!checkInterruptCap()) {
323
+ debug(`[interrupt] Voice overlap blocked — free limit reached`);
324
+ return;
325
+ }
232
326
  debug(`[interrupt] Voice overlap detected (RMS: ${event.rmsLevel.toFixed(4)})`);
327
+ incrementInterruptCount();
233
328
  updateSessionState(activeSessionId, {
234
329
  wasInterrupted: true,
235
330
  partialContentAtInterrupt: event.partialTTSContent,
236
331
  interruptTimestamp: event.overlapTimestamp,
237
332
  awaitingCorrection: true,
238
333
  interruptSource: 'voice',
334
+ interruptCounted: true,
239
335
  });
240
336
  });
241
337
  overlapDetector.on('monitor-error', (err) => {
@@ -299,6 +395,14 @@ export const InterruptPlugin = (userConfig = {}) => {
299
395
  if (msg.role === 'user' && !overlapDetector.isActive()) {
300
396
  const state = getSessionState(sessionId);
301
397
  const userText = extractText(parts);
398
+ if (isLicensed && userText) {
399
+ const detectedLang = detectLanguage(userText);
400
+ const newVoice = getVoiceForLanguage(detectedLang);
401
+ if (newVoice !== config.ttsVoice) {
402
+ ttsStreamer.updateVoice(newVoice);
403
+ debug(`[interrupt] Auto-language: ${detectedLang} → ${newVoice}`);
404
+ }
405
+ }
302
406
  const signal = detectTextInterruption(userText, state);
303
407
  if (signal.detected) {
304
408
  const partialContent = state.lastAssistantContent;
@@ -367,6 +471,10 @@ export const InterruptPlugin = (userConfig = {}) => {
367
471
  await transcribeAndSendV1(sessionID, client, config.whisperModel);
368
472
  }
369
473
  else {
474
+ if (!checkInterruptCap()) {
475
+ await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts", variant: "warning", duration: 6000 } });
476
+ throw new Error('Command handled by interrupt plugin');
477
+ }
370
478
  pttActive = true;
371
479
  if (sessionID) {
372
480
  try {
@@ -0,0 +1,2 @@
1
+ export declare function detectLanguage(text: string): string;
2
+ export declare function getVoiceForLanguage(lang: string): string;
@@ -0,0 +1,46 @@
1
+ /* ------------------------------------------------------------------ */
2
+ /* Auto-language detection + edge-tts voice mapping (Pro-only) */
3
+ /* ------------------------------------------------------------------ */
4
+ const CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/;
5
+ const HIRAGANA_KATAKANA = /[\u3040-\u309f\u30a0-\u30ff]/;
6
+ const CYRILLIC = /[\u0400-\u04ff]/;
7
+ const ARABIC = /[\u0600-\u06ff]/;
8
+ const DEVANAGARI = /[\u0900-\u097f]/;
9
+ const KOREAN = /[\uac00-\ud7af]/;
10
+ const LANG_MAP = [
11
+ { name: 'ja', voice: 'ja-JP-NanamiNeural', test: (t) => HIRAGANA_KATAKANA.test(t) || (CJK_RANGE.test(t) && !KOREAN.test(t) && /[のをにがはがでと]$/.test(t)) },
12
+ { name: 'zh', voice: 'zh-CN-XiaoxiaoNeural', test: (t) => CJK_RANGE.test(t) && !HIRAGANA_KATAKANA.test(t) && !KOREAN.test(t) },
13
+ { name: 'ko', voice: 'ko-KR-SunHiNeural', test: (t) => KOREAN.test(t) },
14
+ { name: 'ru', voice: 'ru-RU-SvetlanaNeural', test: (t) => CYRILLIC.test(t) },
15
+ { name: 'ar', voice: 'ar-SA-ZariyahNeural', test: (t) => ARABIC.test(t) },
16
+ { name: 'hi', voice: 'hi-IN-SwaraNeural', test: (t) => DEVANAGARI.test(t) },
17
+ { name: 'fr', voice: 'fr-FR-DeniseNeural', test: (t) => /[àâçéèêëîïôûùüÿœæ]/i.test(t) || /\b(je|tu|nous|vous|ils|elles|est|sont|avec|pour|dans|sur|pas|les|des|ces|mes|tes|ses|leur|lequel|laquelle|comment|pourquoi|donc|mais|ou|et|car|rien|toujours|jamais|bonjour|merci|oui|non|salut|au|aux|du|de|la|le|ce|cet|cette)\b/i.test(t) },
18
+ { name: 'de', voice: 'de-DE-KatjaNeural', test: (t) => /[äöüß]/i.test(t) || /\b(der|die|das|den|dem|des|ein|eine|einen|einer|einem|ist|sind|war|wird|haben|sein|kein|aber|und|oder|weil|dass|nicht|sehr|auch|noch|schon|immer|nie|bitte|danke|hallo|tschüss|ja|nein|gut|schlecht|groß|klein)\b/i.test(t) },
19
+ { name: 'es', voice: 'es-ES-ElviraNeural', test: (t) => /[áéíóúüñ¿¡]/i.test(t) || /\b(el|la|los|las|un|una|unos|unas|es|son|está|están|tiene|tienen|hay|con|sin|para|por|muy|más|menos|como|qué|quién|dónde|cuándo|cuál|este|esta|ese|esa|aquel|aquella|pero|porque|y|o|no|sí|gracias|hola|adiós|bueno|malo|grande|pequeño)\b/i.test(t) },
20
+ { name: 'pt', voice: 'pt-BR-FranciscaNeural', test: (t) => /[àãáâéêíóôõúç]/i.test(t) },
21
+ { name: 'it', voice: 'it-IT-ElsaNeural', test: (t) => /[àèéìòù]/i.test(t) },
22
+ { name: 'nl', voice: 'nl-NL-MaartenNeural', test: (t) => /\b(de|het|een|van|voor|met|niet|ook|maar)\b/i.test(t) && !/[äöüß]/i.test(t) },
23
+ { name: 'pl', voice: 'pl-PL-AgnieszkaNeural', test: (t) => /[ąćęłńóśźż]/i.test(t) },
24
+ { name: 'da', voice: 'da-DK-ChristelNeural', test: (t) => /[æøå]/i.test(t) },
25
+ { name: 'sv', voice: 'sv-SE-SofieNeural', test: (t) => /[åäö]/i.test(t) && !/[øæ]/i.test(t) && !/\b(og|er|det|til|at|en|den|de|han|hun|jeg|vi|ikke|der|med|sig|men|som|for|på|af|eller|et|det|kan|skal|har|være|været|blevet|have|gør|gøre)\b/i.test(t) },
26
+ { name: 'fi', voice: 'fi-FI-SelmaNeural', test: (t) => /[äö]/i.test(t) && !CYRILLIC.test(t) && !CJK_RANGE.test(t) && /\b(ja|on|se|ei|mutta|että|myös|tai|voidaan|tämä|ovat|hän|kanssa|voi|miten|mikä)\b/i.test(t) },
27
+ { name: 'tr', voice: 'tr-TR-EmelNeural', test: (t) => /[çğıöşü]/i.test(t) && !/[äåæø]/i.test(t) },
28
+ { name: 'ro', voice: 'ro-RO-AlinaNeural', test: (t) => /[ăâîșț]/i.test(t) },
29
+ { name: 'hu', voice: 'hu-HU-NoemiNeural', test: (t) => /[áéíóöúű]/i.test(t) && !/[ăâîșț]/i.test(t) },
30
+ ];
31
+ const DEFAULT_VOICE = 'en-US-AvaNeural';
32
+ export function detectLanguage(text) {
33
+ if (!text)
34
+ return 'en';
35
+ for (const entry of LANG_MAP) {
36
+ if (entry.test(text))
37
+ return entry.name;
38
+ }
39
+ return 'en';
40
+ }
41
+ export function getVoiceForLanguage(lang) {
42
+ if (lang === 'en')
43
+ return DEFAULT_VOICE;
44
+ const entry = LANG_MAP.find(e => e.name === lang);
45
+ return entry?.voice ?? DEFAULT_VOICE;
46
+ }
package/dist/store.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface SessionState {
6
6
  interruptTimestamp: number;
7
7
  awaitingCorrection: boolean;
8
8
  interruptSource?: 'voice' | 'text';
9
+ interruptCounted: boolean;
9
10
  }
10
11
  export declare function getSessionState(sessionId: string): SessionState;
11
12
  export declare function updateSessionState(sessionId: string, updates: Partial<SessionState>): SessionState;
package/dist/store.js CHANGED
@@ -8,6 +8,7 @@ export function getSessionState(sessionId) {
8
8
  partialContentAtInterrupt: '',
9
9
  interruptTimestamp: 0,
10
10
  awaitingCorrection: false,
11
+ interruptCounted: false,
11
12
  });
12
13
  }
13
14
  return sessions.get(sessionId);
@@ -9,10 +9,12 @@ export declare class TTSEngine extends EventEmitter {
9
9
  private generator;
10
10
  private playing;
11
11
  private voice;
12
+ get currentVoice(): string;
12
13
  private rate;
13
14
  private volume;
14
15
  constructor(options?: TTSEngineOptions);
15
16
  speak(text: string): Promise<void>;
16
17
  stop(): void;
18
+ updateVoice(voice: string): void;
17
19
  get isPlaying(): boolean;
18
20
  }
@@ -11,6 +11,7 @@ export class TTSEngine extends EventEmitter {
11
11
  generator = null;
12
12
  playing = false;
13
13
  voice;
14
+ get currentVoice() { return this.voice; }
14
15
  rate;
15
16
  volume;
16
17
  constructor(options = {}) {
@@ -90,6 +91,10 @@ export class TTSEngine extends EventEmitter {
90
91
  }
91
92
  this.playing = false;
92
93
  }
94
+ updateVoice(voice) {
95
+ this.voice = voice;
96
+ debug(`[interrupt-tts] voice updated to ${voice}`);
97
+ }
93
98
  get isPlaying() {
94
99
  return this.playing;
95
100
  }
@@ -29,6 +29,8 @@ export declare class TTSStreamer extends EventEmitter {
29
29
  stop(): void;
30
30
  disable(): void;
31
31
  enable(): void;
32
+ /** Pro-only feature — free tier always uses default voice */
33
+ updateVoice(voice: string): void;
32
34
  getPartialContent(): string;
33
35
  isPlaying(): boolean;
34
36
  resetSession(): void;
package/dist/tts/index.js CHANGED
@@ -119,6 +119,12 @@ export class TTSStreamer extends EventEmitter {
119
119
  enable() {
120
120
  this.enabled = true;
121
121
  }
122
+ /** Pro-only feature — free tier always uses default voice */
123
+ updateVoice(voice) {
124
+ if (voice && voice !== this.engine.currentVoice) {
125
+ this.engine.updateVoice(voice);
126
+ }
127
+ }
122
128
  getPartialContent() {
123
129
  if (this.spokenText.trim()) {
124
130
  return this.spokenText.trim() + "...[interrupted]";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-interrupt-plugin",
3
- "version": "0.4.35",
3
+ "version": "0.4.36",
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",