opencode-smart-voice-notify 1.0.0
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 +21 -0
- package/README.md +200 -0
- package/assets/Machine-alert-beep-sound-effect.mp3 +0 -0
- package/assets/Soft-high-tech-notification-sound-effect.mp3 +0 -0
- package/example.config.jsonc +158 -0
- package/index.js +431 -0
- package/package.json +43 -0
- package/util/config.js +182 -0
- package/util/tts.js +447 -0
package/index.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { loadConfig } from './util/config.js';
|
|
5
|
+
import { createTTS, getTTSConfig } from './util/tts.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenCode Smart Voice Notify Plugin
|
|
9
|
+
*
|
|
10
|
+
* A smart notification plugin with multiple TTS engines (auto-fallback):
|
|
11
|
+
* 1. ElevenLabs (Online, High Quality, Anime-like voices)
|
|
12
|
+
* 2. Edge TTS (Free, Neural voices)
|
|
13
|
+
* 3. Windows SAPI (Offline, Built-in)
|
|
14
|
+
* 4. Local Sound Files (Fallback)
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Smart notification mode (sound-first, tts-first, both, sound-only)
|
|
18
|
+
* - Delayed TTS reminders if user doesn't respond
|
|
19
|
+
* - Follow-up reminders with exponential backoff
|
|
20
|
+
* - Monitor wake and volume boost
|
|
21
|
+
* - Cross-platform support (Windows, macOS, Linux)
|
|
22
|
+
*/
|
|
23
|
+
export const SmartVoiceNotifyPlugin = async ({ $, client }) => {
|
|
24
|
+
const config = getTTSConfig();
|
|
25
|
+
const tts = createTTS({ $, client });
|
|
26
|
+
|
|
27
|
+
const platform = os.platform();
|
|
28
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
29
|
+
const logFile = path.join(configDir, 'smart-voice-notify-debug.log');
|
|
30
|
+
|
|
31
|
+
// Track pending TTS reminders (can be cancelled if user responds)
|
|
32
|
+
const pendingReminders = new Map();
|
|
33
|
+
|
|
34
|
+
// Track last user activity time
|
|
35
|
+
let lastUserActivityTime = Date.now();
|
|
36
|
+
|
|
37
|
+
// Track seen user message IDs to avoid treating message UPDATES as new user activity
|
|
38
|
+
// Key insight: message.updated fires for EVERY modification to a message, not just new messages
|
|
39
|
+
// We only want to treat the FIRST occurrence of each user message as "user activity"
|
|
40
|
+
const seenUserMessageIds = new Set();
|
|
41
|
+
|
|
42
|
+
// Track the timestamp of when session went idle, to detect post-idle user messages
|
|
43
|
+
let lastSessionIdleTime = 0;
|
|
44
|
+
|
|
45
|
+
// Track active permission request to prevent race condition where user responds
|
|
46
|
+
// before async notification code runs. Set on permission.updated, cleared on permission.replied.
|
|
47
|
+
let activePermissionId = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Write debug message to log file
|
|
51
|
+
*/
|
|
52
|
+
const debugLog = (message) => {
|
|
53
|
+
if (!config.debugLog) return;
|
|
54
|
+
try {
|
|
55
|
+
const timestamp = new Date().toISOString();
|
|
56
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
57
|
+
} catch (e) {}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get a random message from an array of messages
|
|
62
|
+
*/
|
|
63
|
+
const getRandomMessage = (messages) => {
|
|
64
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
65
|
+
return 'Notification';
|
|
66
|
+
}
|
|
67
|
+
return messages[Math.floor(Math.random() * messages.length)];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Show a TUI toast notification
|
|
72
|
+
*/
|
|
73
|
+
const showToast = async (message, variant = 'info', duration = 5000) => {
|
|
74
|
+
if (!config.enableToast) return;
|
|
75
|
+
try {
|
|
76
|
+
if (typeof client?.tui?.showToast === 'function') {
|
|
77
|
+
await client.tui.showToast({
|
|
78
|
+
body: {
|
|
79
|
+
message: message,
|
|
80
|
+
variant: variant,
|
|
81
|
+
duration: duration
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Play a sound file from assets
|
|
90
|
+
*/
|
|
91
|
+
const playSound = async (soundFile, loops = 1) => {
|
|
92
|
+
if (!config.enableSound) return;
|
|
93
|
+
try {
|
|
94
|
+
const soundPath = path.isAbsolute(soundFile)
|
|
95
|
+
? soundFile
|
|
96
|
+
: path.join(configDir, soundFile);
|
|
97
|
+
|
|
98
|
+
if (!fs.existsSync(soundPath)) {
|
|
99
|
+
debugLog(`playSound: file not found: ${soundPath}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await tts.wakeMonitor();
|
|
104
|
+
await tts.forceVolume();
|
|
105
|
+
await tts.playAudioFile(soundPath, loops);
|
|
106
|
+
debugLog(`playSound: played ${soundPath} (${loops}x)`);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
debugLog(`playSound error: ${e.message}`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Cancel any pending TTS reminder for a given type
|
|
114
|
+
*/
|
|
115
|
+
const cancelPendingReminder = (type) => {
|
|
116
|
+
const existing = pendingReminders.get(type);
|
|
117
|
+
if (existing) {
|
|
118
|
+
clearTimeout(existing.timeoutId);
|
|
119
|
+
pendingReminders.delete(type);
|
|
120
|
+
debugLog(`cancelPendingReminder: cancelled ${type}`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Cancel all pending TTS reminders (called on user activity)
|
|
126
|
+
*/
|
|
127
|
+
const cancelAllPendingReminders = () => {
|
|
128
|
+
for (const [type, reminder] of pendingReminders.entries()) {
|
|
129
|
+
clearTimeout(reminder.timeoutId);
|
|
130
|
+
debugLog(`cancelAllPendingReminders: cancelled ${type}`);
|
|
131
|
+
}
|
|
132
|
+
pendingReminders.clear();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Schedule a TTS reminder if user doesn't respond within configured delay.
|
|
137
|
+
* The reminder uses a personalized TTS message.
|
|
138
|
+
* @param {string} type - 'idle' or 'permission'
|
|
139
|
+
* @param {string} message - The TTS message to speak
|
|
140
|
+
* @param {object} options - Additional options
|
|
141
|
+
*/
|
|
142
|
+
const scheduleTTSReminder = (type, message, options = {}) => {
|
|
143
|
+
// Check if TTS reminders are enabled
|
|
144
|
+
if (!config.enableTTSReminder) {
|
|
145
|
+
debugLog(`scheduleTTSReminder: TTS reminders disabled`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Get delay from config (in seconds, convert to ms)
|
|
150
|
+
const delaySeconds = type === 'permission'
|
|
151
|
+
? (config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30)
|
|
152
|
+
: (config.idleReminderDelaySeconds || config.ttsReminderDelaySeconds || 30);
|
|
153
|
+
const delayMs = delaySeconds * 1000;
|
|
154
|
+
|
|
155
|
+
// Cancel any existing reminder of this type
|
|
156
|
+
cancelPendingReminder(type);
|
|
157
|
+
|
|
158
|
+
debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s`);
|
|
159
|
+
|
|
160
|
+
const timeoutId = setTimeout(async () => {
|
|
161
|
+
try {
|
|
162
|
+
// Check if reminder was cancelled (user responded)
|
|
163
|
+
if (!pendingReminders.has(type)) {
|
|
164
|
+
debugLog(`scheduleTTSReminder: ${type} was cancelled before firing`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check if user has been active since notification
|
|
169
|
+
const reminder = pendingReminders.get(type);
|
|
170
|
+
if (reminder && lastUserActivityTime > reminder.scheduledAt) {
|
|
171
|
+
debugLog(`scheduleTTSReminder: ${type} skipped - user active since notification`);
|
|
172
|
+
pendingReminders.delete(type);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
debugLog(`scheduleTTSReminder: firing ${type} TTS reminder`);
|
|
177
|
+
|
|
178
|
+
// Get the appropriate reminder messages (more personalized/urgent)
|
|
179
|
+
const reminderMessages = type === 'permission'
|
|
180
|
+
? config.permissionReminderTTSMessages
|
|
181
|
+
: config.idleReminderTTSMessages;
|
|
182
|
+
|
|
183
|
+
const reminderMessage = getRandomMessage(reminderMessages);
|
|
184
|
+
|
|
185
|
+
// Speak the reminder using TTS
|
|
186
|
+
await tts.wakeMonitor();
|
|
187
|
+
await tts.forceVolume();
|
|
188
|
+
await tts.speak(reminderMessage, {
|
|
189
|
+
enableTTS: true,
|
|
190
|
+
fallbackSound: options.fallbackSound
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Clean up
|
|
194
|
+
pendingReminders.delete(type);
|
|
195
|
+
|
|
196
|
+
// Schedule follow-up reminder if configured (exponential backoff or fixed)
|
|
197
|
+
if (config.enableFollowUpReminders) {
|
|
198
|
+
const followUpCount = (reminder?.followUpCount || 0) + 1;
|
|
199
|
+
const maxFollowUps = config.maxFollowUpReminders || 3;
|
|
200
|
+
|
|
201
|
+
if (followUpCount < maxFollowUps) {
|
|
202
|
+
// Schedule another reminder with optional backoff
|
|
203
|
+
const backoffMultiplier = config.reminderBackoffMultiplier || 1.5;
|
|
204
|
+
const nextDelay = delaySeconds * Math.pow(backoffMultiplier, followUpCount);
|
|
205
|
+
|
|
206
|
+
debugLog(`scheduleTTSReminder: scheduling follow-up ${followUpCount + 1}/${maxFollowUps} in ${nextDelay}s`);
|
|
207
|
+
|
|
208
|
+
const followUpTimeoutId = setTimeout(async () => {
|
|
209
|
+
const followUpReminder = pendingReminders.get(type);
|
|
210
|
+
if (!followUpReminder || lastUserActivityTime > followUpReminder.scheduledAt) {
|
|
211
|
+
pendingReminders.delete(type);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const followUpMessage = getRandomMessage(reminderMessages);
|
|
216
|
+
await tts.wakeMonitor();
|
|
217
|
+
await tts.forceVolume();
|
|
218
|
+
await tts.speak(followUpMessage, {
|
|
219
|
+
enableTTS: true,
|
|
220
|
+
fallbackSound: options.fallbackSound
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
pendingReminders.delete(type);
|
|
224
|
+
}, nextDelay * 1000);
|
|
225
|
+
|
|
226
|
+
pendingReminders.set(type, {
|
|
227
|
+
timeoutId: followUpTimeoutId,
|
|
228
|
+
scheduledAt: Date.now(),
|
|
229
|
+
followUpCount
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch (e) {
|
|
234
|
+
debugLog(`scheduleTTSReminder error: ${e.message}`);
|
|
235
|
+
pendingReminders.delete(type);
|
|
236
|
+
}
|
|
237
|
+
}, delayMs);
|
|
238
|
+
|
|
239
|
+
// Store the pending reminder
|
|
240
|
+
pendingReminders.set(type, {
|
|
241
|
+
timeoutId,
|
|
242
|
+
scheduledAt: Date.now(),
|
|
243
|
+
followUpCount: 0
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Smart notification: play sound first, then schedule TTS reminder
|
|
249
|
+
* @param {string} type - 'idle' or 'permission'
|
|
250
|
+
* @param {object} options - Notification options
|
|
251
|
+
*/
|
|
252
|
+
const smartNotify = async (type, options = {}) => {
|
|
253
|
+
const {
|
|
254
|
+
soundFile,
|
|
255
|
+
soundLoops = 1,
|
|
256
|
+
ttsMessage,
|
|
257
|
+
fallbackSound
|
|
258
|
+
} = options;
|
|
259
|
+
|
|
260
|
+
// Step 1: Play the immediate sound notification
|
|
261
|
+
if (soundFile) {
|
|
262
|
+
await playSound(soundFile, soundLoops);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Step 2: Schedule TTS reminder if user doesn't respond
|
|
266
|
+
if (config.enableTTSReminder && ttsMessage) {
|
|
267
|
+
scheduleTTSReminder(type, ttsMessage, { fallbackSound });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Step 3: If TTS-first mode is enabled, also speak immediately
|
|
271
|
+
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
272
|
+
const immediateMessage = type === 'permission'
|
|
273
|
+
? getRandomMessage(config.permissionTTSMessages)
|
|
274
|
+
: getRandomMessage(config.idleTTSMessages);
|
|
275
|
+
|
|
276
|
+
await tts.speak(immediateMessage, {
|
|
277
|
+
enableTTS: true,
|
|
278
|
+
fallbackSound
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
event: async ({ event }) => {
|
|
285
|
+
try {
|
|
286
|
+
// ========================================
|
|
287
|
+
// USER ACTIVITY DETECTION
|
|
288
|
+
// Cancels pending TTS reminders when user responds
|
|
289
|
+
// ========================================
|
|
290
|
+
// NOTE: OpenCode event types (as of SDK v1.0.203):
|
|
291
|
+
// - message.updated: fires when a message is added/updated (use properties.info.role to check user vs assistant)
|
|
292
|
+
// - permission.replied: fires when user responds to a permission request
|
|
293
|
+
// - session.created: fires when a new session starts
|
|
294
|
+
//
|
|
295
|
+
// CRITICAL: message.updated fires for EVERY modification to a message (not just creation).
|
|
296
|
+
// Context-injector and other plugins can trigger multiple updates for the same message.
|
|
297
|
+
// We must only treat NEW user messages (after session.idle) as actual user activity.
|
|
298
|
+
|
|
299
|
+
if (event.type === "message.updated") {
|
|
300
|
+
const messageInfo = event.properties?.info;
|
|
301
|
+
const messageId = messageInfo?.id;
|
|
302
|
+
const isUserMessage = messageInfo?.role === 'user';
|
|
303
|
+
|
|
304
|
+
if (isUserMessage && messageId) {
|
|
305
|
+
// Check if this is a NEW user message we haven't seen before
|
|
306
|
+
const isNewMessage = !seenUserMessageIds.has(messageId);
|
|
307
|
+
|
|
308
|
+
// Check if this message arrived AFTER the last session.idle
|
|
309
|
+
// This is the key: only a message sent AFTER idle indicates user responded
|
|
310
|
+
const messageTime = messageInfo?.time?.created;
|
|
311
|
+
const isAfterIdle = lastSessionIdleTime > 0 && messageTime && (messageTime * 1000) > lastSessionIdleTime;
|
|
312
|
+
|
|
313
|
+
if (isNewMessage) {
|
|
314
|
+
seenUserMessageIds.add(messageId);
|
|
315
|
+
|
|
316
|
+
// Only cancel reminders if this is a NEW message AFTER session went idle
|
|
317
|
+
// OR if there are no pending reminders (initial message before any notifications)
|
|
318
|
+
if (isAfterIdle || pendingReminders.size === 0) {
|
|
319
|
+
if (isAfterIdle) {
|
|
320
|
+
lastUserActivityTime = Date.now();
|
|
321
|
+
cancelAllPendingReminders();
|
|
322
|
+
debugLog(`NEW user message AFTER idle: ${messageId} - cancelled pending reminders`);
|
|
323
|
+
} else {
|
|
324
|
+
debugLog(`Initial user message (before any idle): ${messageId} - no reminders to cancel`);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
debugLog(`Ignored: user message ${messageId} created BEFORE session.idle (time=${messageTime}, idleTime=${lastSessionIdleTime})`);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
// This is an UPDATE to an existing message (e.g., context injection)
|
|
331
|
+
debugLog(`Ignored: update to existing user message ${messageId} (not new activity)`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (event.type === "permission.replied") {
|
|
337
|
+
// User responded to a permission request (granted or denied)
|
|
338
|
+
// Structure: event.properties.{ sessionID, permissionID, response }
|
|
339
|
+
// CRITICAL: Clear activePermissionId FIRST to prevent race condition
|
|
340
|
+
// where permission.updated handler is still running async operations
|
|
341
|
+
const repliedPermissionId = event.properties?.permissionID;
|
|
342
|
+
if (activePermissionId === repliedPermissionId) {
|
|
343
|
+
activePermissionId = null;
|
|
344
|
+
debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId}`);
|
|
345
|
+
}
|
|
346
|
+
lastUserActivityTime = Date.now();
|
|
347
|
+
cancelPendingReminder('permission'); // Cancel permission-specific reminder
|
|
348
|
+
debugLog(`Permission replied: ${event.type} (response=${event.properties?.response}) - cancelled permission reminder`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (event.type === "session.created") {
|
|
352
|
+
// New session started - reset tracking state
|
|
353
|
+
lastUserActivityTime = Date.now();
|
|
354
|
+
lastSessionIdleTime = 0;
|
|
355
|
+
activePermissionId = null;
|
|
356
|
+
seenUserMessageIds.clear();
|
|
357
|
+
cancelAllPendingReminders();
|
|
358
|
+
debugLog(`Session created: ${event.type} - reset all tracking state`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ========================================
|
|
362
|
+
// NOTIFICATION 1: Session Idle (Agent Finished)
|
|
363
|
+
// ========================================
|
|
364
|
+
if (event.type === "session.idle") {
|
|
365
|
+
const sessionID = event.properties?.sessionID;
|
|
366
|
+
if (!sessionID) return;
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const session = await client.session.get({ path: { id: sessionID } });
|
|
370
|
+
if (session?.data?.parentID) {
|
|
371
|
+
debugLog(`session.idle: skipped (sub-session ${sessionID})`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
} catch (e) {}
|
|
375
|
+
|
|
376
|
+
// Record the time session went idle - used to filter out pre-idle messages
|
|
377
|
+
lastSessionIdleTime = Date.now();
|
|
378
|
+
|
|
379
|
+
debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
|
|
380
|
+
await showToast("✅ Agent has finished working", "success", 5000);
|
|
381
|
+
|
|
382
|
+
// Smart notification: sound first, TTS reminder later
|
|
383
|
+
await smartNotify('idle', {
|
|
384
|
+
soundFile: config.idleSound,
|
|
385
|
+
soundLoops: 1,
|
|
386
|
+
ttsMessage: getRandomMessage(config.idleTTSMessages),
|
|
387
|
+
fallbackSound: config.idleSound
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ========================================
|
|
392
|
+
// NOTIFICATION 2: Permission Request
|
|
393
|
+
// ========================================
|
|
394
|
+
if (event.type === "permission.updated") {
|
|
395
|
+
// CRITICAL: Capture permissionID IMMEDIATELY (before any async work)
|
|
396
|
+
// This prevents race condition where user responds before we finish notifying
|
|
397
|
+
const permissionId = event.properties?.permissionID;
|
|
398
|
+
activePermissionId = permissionId;
|
|
399
|
+
|
|
400
|
+
debugLog(`permission.updated: notifying (permissionId=${permissionId})`);
|
|
401
|
+
await showToast("⚠️ Permission request requires your attention", "warning", 8000);
|
|
402
|
+
|
|
403
|
+
// CHECK: Did user already respond while we were showing toast?
|
|
404
|
+
if (activePermissionId !== permissionId) {
|
|
405
|
+
debugLog(`permission.updated: aborted - user already responded (activePermissionId cleared)`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Smart notification: sound first, TTS reminder later
|
|
410
|
+
await smartNotify('permission', {
|
|
411
|
+
soundFile: config.permissionSound,
|
|
412
|
+
soundLoops: 2,
|
|
413
|
+
ttsMessage: getRandomMessage(config.permissionTTSMessages),
|
|
414
|
+
fallbackSound: config.permissionSound
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Final check after smartNotify: if user responded during sound playback, cancel the scheduled reminder
|
|
418
|
+
if (activePermissionId !== permissionId) {
|
|
419
|
+
debugLog(`permission.updated: user responded during notification - cancelling any scheduled reminder`);
|
|
420
|
+
cancelPendingReminder('permission');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch (e) {
|
|
424
|
+
debugLog(`event handler error: ${e.message}`);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Default export for compatibility
|
|
431
|
+
export default SmartVoiceNotifyPlugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-smart-voice-notify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI) and intelligent reminder system",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "MasuRii",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"opencode",
|
|
11
|
+
"opencode-plugins",
|
|
12
|
+
"plugin",
|
|
13
|
+
"notification",
|
|
14
|
+
"tts",
|
|
15
|
+
"text-to-speech",
|
|
16
|
+
"elevenlabs",
|
|
17
|
+
"edge-tts",
|
|
18
|
+
"sapi",
|
|
19
|
+
"voice",
|
|
20
|
+
"alert",
|
|
21
|
+
"smart"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"util/",
|
|
26
|
+
"assets/",
|
|
27
|
+
"example.config.jsonc"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/MasuRii/opencode-smart-voice-notify.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/MasuRii/opencode-smart-voice-notify/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/MasuRii/opencode-smart-voice-notify#readme",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@elevenlabs/elevenlabs-js": "^2.28.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/util/config.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Basic JSONC parser that strips single-line and multi-line comments.
|
|
8
|
+
* @param {string} jsonc
|
|
9
|
+
* @returns {any}
|
|
10
|
+
*/
|
|
11
|
+
const parseJSONC = (jsonc) => {
|
|
12
|
+
const stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m);
|
|
13
|
+
return JSON.parse(stripped);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the directory where this plugin is installed.
|
|
18
|
+
* Used to find bundled assets like example.config.jsonc
|
|
19
|
+
*/
|
|
20
|
+
const getPluginDir = () => {
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = path.dirname(__filename);
|
|
23
|
+
return path.dirname(__dirname); // Go up from util/ to plugin root
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate a minimal default configuration file content.
|
|
28
|
+
* This provides a working config with helpful comments pointing to the full example.
|
|
29
|
+
*/
|
|
30
|
+
const generateDefaultConfig = () => {
|
|
31
|
+
return `{
|
|
32
|
+
// ============================================================
|
|
33
|
+
// OpenCode Smart Voice Notify - Configuration
|
|
34
|
+
// ============================================================
|
|
35
|
+
// This file was auto-generated with minimal defaults.
|
|
36
|
+
// For ALL available options, see the full example config:
|
|
37
|
+
// node_modules/opencode-smart-voice-notify/example.config.jsonc
|
|
38
|
+
// Or visit: https://github.com/MasuRii/opencode-smart-voice-notify#configuration
|
|
39
|
+
// ============================================================
|
|
40
|
+
|
|
41
|
+
// NOTIFICATION MODE
|
|
42
|
+
// 'sound-first' - Play sound immediately, TTS reminder after delay (RECOMMENDED)
|
|
43
|
+
// 'tts-first' - Speak TTS immediately, no sound
|
|
44
|
+
// 'both' - Play sound AND speak TTS immediately
|
|
45
|
+
// 'sound-only' - Only play sound, no TTS at all
|
|
46
|
+
"notificationMode": "sound-first",
|
|
47
|
+
|
|
48
|
+
// ============================================================
|
|
49
|
+
// TTS ENGINE SELECTION
|
|
50
|
+
// ============================================================
|
|
51
|
+
// 'elevenlabs' - Best quality (requires API key, free tier: 10k chars/month)
|
|
52
|
+
// 'edge' - Good quality (free, requires: pip install edge-tts)
|
|
53
|
+
// 'sapi' - Windows built-in (free, offline, robotic)
|
|
54
|
+
"ttsEngine": "edge",
|
|
55
|
+
"enableTTS": true,
|
|
56
|
+
|
|
57
|
+
// ============================================================
|
|
58
|
+
// ELEVENLABS SETTINGS (Optional - for best quality)
|
|
59
|
+
// ============================================================
|
|
60
|
+
// Get your free API key from: https://elevenlabs.io/app/settings/api-keys
|
|
61
|
+
// Uncomment and add your key to use ElevenLabs:
|
|
62
|
+
// "elevenLabsApiKey": "YOUR_API_KEY_HERE",
|
|
63
|
+
// "elevenLabsVoiceId": "cgSgspJ2msm6clMCkdW9",
|
|
64
|
+
|
|
65
|
+
// ============================================================
|
|
66
|
+
// EDGE TTS SETTINGS (Default - Free Neural Voices)
|
|
67
|
+
// ============================================================
|
|
68
|
+
// Requires: pip install edge-tts
|
|
69
|
+
"edgeVoice": "en-US-AnaNeural",
|
|
70
|
+
"edgePitch": "+50Hz",
|
|
71
|
+
"edgeRate": "+10%",
|
|
72
|
+
|
|
73
|
+
// ============================================================
|
|
74
|
+
// TTS REMINDER SETTINGS
|
|
75
|
+
// ============================================================
|
|
76
|
+
"enableTTSReminder": true,
|
|
77
|
+
"ttsReminderDelaySeconds": 30,
|
|
78
|
+
"permissionReminderDelaySeconds": 20,
|
|
79
|
+
"enableFollowUpReminders": true,
|
|
80
|
+
"maxFollowUpReminders": 3,
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// SOUND FILES (Relative to ~/.config/opencode/)
|
|
84
|
+
// ============================================================
|
|
85
|
+
// NOTE: You need to copy the sound files to your config directory!
|
|
86
|
+
// Copy from: node_modules/opencode-smart-voice-notify/assets/
|
|
87
|
+
// To: ~/.config/opencode/assets/
|
|
88
|
+
"idleSound": "assets/Soft-high-tech-notification-sound-effect.mp3",
|
|
89
|
+
"permissionSound": "assets/Machine-alert-beep-sound-effect.mp3",
|
|
90
|
+
|
|
91
|
+
// ============================================================
|
|
92
|
+
// GENERAL SETTINGS
|
|
93
|
+
// ============================================================
|
|
94
|
+
"enableSound": true,
|
|
95
|
+
"enableToast": true,
|
|
96
|
+
"wakeMonitor": true,
|
|
97
|
+
"forceVolume": true,
|
|
98
|
+
"debugLog": false
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Copy bundled assets (sound files) to the OpenCode config directory.
|
|
105
|
+
* @param {string} configDir - The OpenCode config directory path
|
|
106
|
+
*/
|
|
107
|
+
const copyBundledAssets = (configDir) => {
|
|
108
|
+
try {
|
|
109
|
+
const pluginDir = getPluginDir();
|
|
110
|
+
const sourceAssetsDir = path.join(pluginDir, 'assets');
|
|
111
|
+
const targetAssetsDir = path.join(configDir, 'assets');
|
|
112
|
+
|
|
113
|
+
// Check if source assets exist (they should be bundled with the plugin)
|
|
114
|
+
if (!fs.existsSync(sourceAssetsDir)) {
|
|
115
|
+
return; // No bundled assets to copy
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Create target assets directory if it doesn't exist
|
|
119
|
+
if (!fs.existsSync(targetAssetsDir)) {
|
|
120
|
+
fs.mkdirSync(targetAssetsDir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Copy each asset file if it doesn't already exist in target
|
|
124
|
+
const assetFiles = fs.readdirSync(sourceAssetsDir);
|
|
125
|
+
for (const file of assetFiles) {
|
|
126
|
+
const sourcePath = path.join(sourceAssetsDir, file);
|
|
127
|
+
const targetPath = path.join(targetAssetsDir, file);
|
|
128
|
+
|
|
129
|
+
// Only copy if target doesn't exist (don't overwrite user customizations)
|
|
130
|
+
if (!fs.existsSync(targetPath) && fs.statSync(sourcePath).isFile()) {
|
|
131
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// Silently fail - assets are optional
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Loads a configuration file from the OpenCode config directory.
|
|
141
|
+
* If the file doesn't exist, creates a default config file.
|
|
142
|
+
* @param {string} name - Name of the config file (without .jsonc extension)
|
|
143
|
+
* @param {object} defaults - Default values if file doesn't exist or is invalid
|
|
144
|
+
* @returns {object}
|
|
145
|
+
*/
|
|
146
|
+
export const loadConfig = (name, defaults = {}) => {
|
|
147
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
148
|
+
const filePath = path.join(configDir, `${name}.jsonc`);
|
|
149
|
+
|
|
150
|
+
if (!fs.existsSync(filePath)) {
|
|
151
|
+
// Auto-create the default config file
|
|
152
|
+
try {
|
|
153
|
+
// Ensure config directory exists
|
|
154
|
+
if (!fs.existsSync(configDir)) {
|
|
155
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Write the default config
|
|
159
|
+
const defaultConfig = generateDefaultConfig();
|
|
160
|
+
fs.writeFileSync(filePath, defaultConfig, 'utf-8');
|
|
161
|
+
|
|
162
|
+
// Also copy bundled assets (sound files) to the config directory
|
|
163
|
+
copyBundledAssets(configDir);
|
|
164
|
+
|
|
165
|
+
// Parse and return the newly created config merged with defaults
|
|
166
|
+
const config = parseJSONC(defaultConfig);
|
|
167
|
+
return { ...defaults, ...config };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// If we can't create the file, just return defaults
|
|
170
|
+
return defaults;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
176
|
+
const config = parseJSONC(content);
|
|
177
|
+
return { ...defaults, ...config };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Silently return defaults - don't use console.error as it breaks TUI
|
|
180
|
+
return defaults;
|
|
181
|
+
}
|
|
182
|
+
};
|