opencode-smart-voice-notify 1.3.0 → 1.3.2

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/util/config.js CHANGED
@@ -1,1058 +1,1058 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import { fileURLToPath } from 'url';
5
-
6
- /**
7
- * Debug logging to file (no console output).
8
- * Logs are written to ~/.config/opencode/logs/smart-voice-notify-debug.log
9
- * @param {string} message - Message to log
10
- * @param {string} configDir - Config directory path
11
- */
12
- const debugLogToFile = (message, configDir) => {
13
- try {
14
- const logsDir = path.join(configDir, 'logs');
15
- if (!fs.existsSync(logsDir)) {
16
- fs.mkdirSync(logsDir, { recursive: true });
17
- }
18
- const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
19
- const timestamp = new Date().toISOString();
20
- fs.appendFileSync(logFile, `[${timestamp}] [config] ${message}\n`);
21
- } catch (e) {
22
- // Silently fail - logging should never break the plugin
23
- }
24
- };
25
-
26
- /**
27
- * Basic JSONC parser that strips single-line and multi-line comments,
28
- * and handles trailing commas (which Prettier often adds).
29
- * @param {string} jsonc
30
- * @returns {any}
31
- */
32
- export const parseJSONC = (jsonc) => {
33
- // Step 1: Strip comments while preserving strings
34
- // This regex matches strings (handling escaped quotes) or comments
35
- // If it's a comment, we replace it with empty string
36
- let stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m);
37
-
38
- // Step 2: Strip trailing commas (e.g. [1, 2,] or {"a":1,})
39
- // This helps when formatters like Prettier are used
40
- stripped = stripped.replace(/,(\s*[\]}])/g, '$1');
41
-
42
- // Step 3: Handle literal control characters that might be present
43
- // JSON.parse fails on literal control characters (U+0000 to U+001F).
44
- // Some are allowed as whitespace (space, tab, newline, cr), but literal
45
- // tabs or newlines INSIDE strings are strictly forbidden.
46
- // We'll strip most of them, but preserve allowed whitespace outside strings.
47
- // A safer approach for user-edited files is to remove characters that
48
- // definitely shouldn't be there.
49
- stripped = stripped.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
50
-
51
- return JSON.parse(stripped);
52
- };
53
-
54
- /**
55
- * Helper to format JSON values for the template.
56
- * @param {any} val
57
- * @param {number} indent
58
- * @returns {string}
59
- */
60
- export const formatJSON = (val, indent = 0) => {
61
- const json = JSON.stringify(val, null, 4);
62
- return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json;
63
- };
64
-
65
- /**
66
- * Deep merge two objects. User values take precedence over defaults.
67
- * - For objects: recursively merge, adding new keys from defaults
68
- * - For arrays: user's array completely replaces default (no merge)
69
- * - For primitives: user's value takes precedence if it exists
70
- *
71
- * @param {object} defaults - The default configuration object
72
- * @param {object} user - The user's existing configuration object
73
- * @returns {object} Merged configuration with user values preserved
74
- */
75
- export const deepMerge = (defaults, user) => {
76
- // If user value doesn't exist, use default
77
- if (user === undefined || user === null) {
78
- return defaults;
79
- }
80
-
81
- // If either is not an object (or is array), user value wins
82
- if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
83
- return user;
84
- }
85
- if (typeof user !== 'object' || user === null || Array.isArray(user)) {
86
- return user;
87
- }
88
-
89
- // Both are objects - merge them
90
- const result = { ...user };
91
-
92
- for (const key of Object.keys(defaults)) {
93
- if (!(key in user)) {
94
- // Key doesn't exist in user config - add it from defaults
95
- result[key] = defaults[key];
96
- } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
97
- // Both have this key and it's an object - recurse
98
- result[key] = deepMerge(defaults[key], user[key]);
99
- }
100
- // else: user has this key and it's not an object to merge - keep user's value
101
- }
102
-
103
- return result;
104
- };
105
-
106
- /**
107
- * Get the default configuration object.
108
- * This is the source of truth for all default values.
109
- * @returns {object} Default configuration object
110
- */
111
- export const getDefaultConfigObject = () => ({
112
-
113
- _configVersion: null, // Will be set by caller
114
- enabled: true,
115
- notificationMode: 'sound-first',
116
- enableTTSReminder: true,
117
- enableIdleNotification: true,
118
- enablePermissionNotification: true,
119
- enableQuestionNotification: true,
120
- enableErrorNotification: false,
121
- enableIdleReminder: true,
122
- enablePermissionReminder: true,
123
- enableQuestionReminder: true,
124
- enableErrorReminder: false,
125
- ttsReminderDelaySeconds: 30,
126
- idleReminderDelaySeconds: 30,
127
- permissionReminderDelaySeconds: 20,
128
- enableFollowUpReminders: true,
129
- maxFollowUpReminders: 3,
130
- reminderBackoffMultiplier: 1.5,
131
- ttsEngine: 'elevenlabs',
132
- enableTTS: true,
133
- // elevenLabsApiKey is intentionally omitted - users must set it
134
- elevenLabsVoiceId: 'cgSgspJ2msm6clMCkdW9',
135
- elevenLabsModel: 'eleven_turbo_v2_5',
136
- elevenLabsStability: 0.5,
137
- elevenLabsSimilarity: 0.75,
138
- elevenLabsStyle: 0.5,
139
- edgeVoice: 'en-US-JennyNeural',
140
- edgePitch: '+0Hz',
141
- edgeRate: '+10%',
142
- sapiVoice: 'Microsoft Zira Desktop',
143
- sapiRate: -1,
144
- sapiPitch: 'medium',
145
- sapiVolume: 'loud',
146
- openaiTtsEndpoint: '',
147
- openaiTtsApiKey: '',
148
- openaiTtsModel: 'tts-1',
149
- openaiTtsVoice: 'alloy',
150
- openaiTtsFormat: 'mp3',
151
- openaiTtsSpeed: 1.0,
152
- idleTTSMessages: [
153
- "All done! Your task has been completed successfully.",
154
- "Hey there! I finished working on your request.",
155
- "Task complete! Ready for your review whenever you are.",
156
- "Good news! Everything is done and ready for you.",
157
- "Finished! Let me know if you need anything else."
158
- ],
159
- permissionTTSMessages: [
160
- "Attention please! I need your permission to continue.",
161
- "Hey! Quick approval needed to proceed with the task.",
162
- "Heads up! There is a permission request waiting for you.",
163
- "Excuse me! I need your authorization before I can continue.",
164
- "Permission required! Please review and approve when ready."
165
- ],
166
- permissionTTSMessagesMultiple: [
167
- "Attention please! There are {count} permission requests waiting for your approval.",
168
- "Hey! {count} permissions need your approval to continue.",
169
- "Heads up! You have {count} pending permission requests.",
170
- "Excuse me! I need your authorization for {count} different actions.",
171
- "{count} permissions required! Please review and approve when ready."
172
- ],
173
- idleReminderTTSMessages: [
174
- "Hey, are you still there? Your task has been waiting for review.",
175
- "Just a gentle reminder - I finished your request a while ago!",
176
- "Hello? I completed your task. Please take a look when you can.",
177
- "Still waiting for you! The work is done and ready for review.",
178
- "Knock knock! Your completed task is patiently waiting for you."
179
- ],
180
- permissionReminderTTSMessages: [
181
- "Hey! I still need your permission to continue. Please respond!",
182
- "Reminder: There is a pending permission request. I cannot proceed without you.",
183
- "Hello? I am waiting for your approval. This is getting urgent!",
184
- "Please check your screen! I really need your permission to move forward.",
185
- "Still waiting for authorization! The task is on hold until you respond."
186
- ],
187
- permissionReminderTTSMessagesMultiple: [
188
- "Hey! I still need your approval for {count} permissions. Please respond!",
189
- "Reminder: There are {count} pending permission requests. I cannot proceed without you.",
190
- "Hello? I am waiting for your approval on {count} items. This is getting urgent!",
191
- "Please check your screen! {count} permissions are waiting for your response.",
192
- "Still waiting for authorization on {count} requests! The task is on hold."
193
- ],
194
- permissionBatchWindowMs: 800,
195
- questionTTSMessages: [
196
- "Hey! I have a question for you. Please check your screen.",
197
- "Attention! I need your input to continue.",
198
- "Quick question! Please take a look when you have a moment.",
199
- "I need some clarification. Could you please respond?",
200
- "Question time! Your input is needed to proceed."
201
- ],
202
- questionTTSMessagesMultiple: [
203
- "Hey! I have {count} questions for you. Please check your screen.",
204
- "Attention! I need your input on {count} items to continue.",
205
- "{count} questions need your attention. Please take a look!",
206
- "I need some clarifications. There are {count} questions waiting for you.",
207
- "Question time! {count} questions need your response to proceed."
208
- ],
209
- questionReminderTTSMessages: [
210
- "Hey! I am still waiting for your answer. Please check the questions!",
211
- "Reminder: There is a question waiting for your response.",
212
- "Hello? I need your input to continue. Please respond when you can.",
213
- "Still waiting for your answer! The task is on hold.",
214
- "Your input is needed! Please check the pending question."
215
- ],
216
- questionReminderTTSMessagesMultiple: [
217
- "Hey! I am still waiting for answers to {count} questions. Please respond!",
218
- "Reminder: There are {count} questions waiting for your response.",
219
- "Hello? I need your input on {count} items. Please respond when you can.",
220
- "Still waiting for your answers on {count} questions! The task is on hold.",
221
- "Your input is needed! {count} questions are pending your response."
222
- ],
223
- questionReminderDelaySeconds: 25,
224
- questionBatchWindowMs: 800,
225
- errorTTSMessages: [
226
- "Oops! Something went wrong. Please check for errors.",
227
- "Alert! The agent encountered an error and needs your attention.",
228
- "Error detected! Please review the issue when you can.",
229
- "Houston, we have a problem! An error occurred during the task.",
230
- "Heads up! There was an error that requires your attention."
231
- ],
232
- errorTTSMessagesMultiple: [
233
- "Oops! There are {count} errors that need your attention.",
234
- "Alert! The agent encountered {count} errors. Please review.",
235
- "{count} errors detected! Please check when you can.",
236
- "Houston, we have {count} problems! Multiple errors occurred.",
237
- "Heads up! {count} errors require your attention."
238
- ],
239
- errorReminderTTSMessages: [
240
- "Hey! There's still an error waiting for your attention.",
241
- "Reminder: An error occurred and hasn't been addressed yet.",
242
- "The agent is stuck! Please check the error when you can.",
243
- "Still waiting! That error needs your attention.",
244
- "Don't forget! There's an unresolved error in your session."
245
- ],
246
- errorReminderTTSMessagesMultiple: [
247
- "Hey! There are still {count} errors waiting for your attention.",
248
- "Reminder: {count} errors occurred and haven't been addressed yet.",
249
- "The agent is stuck! Please check the {count} errors when you can.",
250
- "Still waiting! {count} errors need your attention.",
251
- "Don't forget! There are {count} unresolved errors in your session."
252
- ],
253
- errorReminderDelaySeconds: 20,
254
- enableAIMessages: false,
255
- aiEndpoint: 'http://localhost:11434/v1',
256
- aiModel: 'llama3',
257
- aiApiKey: '',
258
- aiTimeout: 15000,
259
- aiFallbackToStatic: true,
260
- enableContextAwareAI: false,
261
- aiPrompts: {
262
- idle: "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.",
263
- permission: "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.",
264
- question: "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.",
265
- error: "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.",
266
- idleReminder: "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.",
267
- permissionReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.",
268
- questionReminder: "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.",
269
- errorReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes."
270
- },
271
- idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3',
272
- permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
273
- questionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
274
- errorSound: 'assets/Machine-alert-beep-sound-effect.mp3',
275
- wakeMonitor: true,
276
- forceVolume: false,
277
- volumeThreshold: 50,
278
- enableToast: true,
279
- enableSound: true,
280
- enableDesktopNotification: true,
281
- desktopNotificationTimeout: 5,
282
- showProjectInNotification: true,
283
- suppressWhenFocused: true,
284
- alwaysNotify: false,
285
- enableWebhook: false,
286
- webhookUrl: "",
287
- webhookUsername: "OpenCode Notify",
288
- webhookEvents: ["idle", "permission", "error", "question"],
289
- webhookMentionOnPermission: false,
290
- soundThemeDir: "",
291
- randomizeSoundFromTheme: true,
292
- perProjectSounds: false,
293
- projectSoundSeed: 0,
294
- idleThresholdSeconds: 60,
295
- debugLog: false
296
- });
297
-
298
- /**
299
- * Find new fields that exist in defaults but not in user config.
300
- * Used for logging what was added during migration.
301
- * @param {object} defaults
302
- * @param {object} user
303
- * @param {string} prefix
304
- * @returns {string[]} Array of field paths that were added
305
- */
306
- export const findNewFields = (defaults, user, prefix = '') => {
307
-
308
- const newFields = [];
309
-
310
- if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
311
- return newFields;
312
- }
313
-
314
- for (const key of Object.keys(defaults)) {
315
- const fieldPath = prefix ? `${prefix}.${key}` : key;
316
-
317
- if (!(key in user)) {
318
- newFields.push(fieldPath);
319
- } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
320
- if (typeof user[key] === 'object' && user[key] !== null && !Array.isArray(user[key])) {
321
- newFields.push(...findNewFields(defaults[key], user[key], fieldPath));
322
- }
323
- }
324
- }
325
-
326
- return newFields;
327
- };
328
-
329
- /**
330
- * Get the directory where this plugin is installed.
331
- * Used to find bundled assets like example.config.jsonc
332
- */
333
- const getPluginDir = () => {
334
- const __filename = fileURLToPath(import.meta.url);
335
- const __dirname = path.dirname(__filename);
336
- return path.dirname(__dirname); // Go up from util/ to plugin root
337
- };
338
-
339
- /**
340
- * Generate a comprehensive default configuration file content.
341
- * This provides users with ALL available options fully documented.
342
- * @param {object} overrides - Existing configuration to preserve
343
- * @param {string} version - Current version to set in config
344
- */
345
- const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
346
- return `{
347
- // ============================================================
348
- // OpenCode Smart Voice Notify - Configuration
349
- // ============================================================
350
- //
351
- // This file was auto-generated with all available options.
352
- // Customize the settings below to your preference.
353
- //
354
- // Sound files have been automatically copied to:
355
- // ~/.config/opencode/assets/
356
- //
357
- // Documentation: https://github.com/MasuRii/opencode-smart-voice-notify
358
- //
359
- // ============================================================
360
-
361
- // Internal version tracking - DO NOT REMOVE
362
- "_configVersion": "${version}",
363
-
364
- // ============================================================
365
- // PLUGIN ENABLE/DISABLE
366
- // ============================================================
367
- // Master switch to enable or disable the entire plugin.
368
- // Set to false to disable all notifications without uninstalling.
369
- "enabled": ${overrides.enabled !== undefined ? overrides.enabled : true},
370
-
371
- // ============================================================
372
- // GRANULAR NOTIFICATION CONTROL
373
- // ============================================================
374
- // Enable or disable notifications for specific event types.
375
- // If disabled, no sound, TTS, desktop, or webhook notifications
376
- // will be sent for that specific category.
377
- "enableIdleNotification": ${overrides.enableIdleNotification !== undefined ? overrides.enableIdleNotification : true}, // Agent finished work
378
- "enablePermissionNotification": ${overrides.enablePermissionNotification !== undefined ? overrides.enablePermissionNotification : true}, // Agent needs permission
379
- "enableQuestionNotification": ${overrides.enableQuestionNotification !== undefined ? overrides.enableQuestionNotification : true}, // Agent asks a question
380
- "enableErrorNotification": ${overrides.enableErrorNotification !== undefined ? overrides.enableErrorNotification : false}, // Agent encountered an error
381
-
382
- // Enable or disable reminders for specific event types.
383
- // If disabled, the initial notification will still fire, but no
384
- // follow-up TTS reminders will be scheduled.
385
- "enableIdleReminder": ${overrides.enableIdleReminder !== undefined ? overrides.enableIdleReminder : true},
386
- "enablePermissionReminder": ${overrides.enablePermissionReminder !== undefined ? overrides.enablePermissionReminder : true},
387
- "enableQuestionReminder": ${overrides.enableQuestionReminder !== undefined ? overrides.enableQuestionReminder : true},
388
- "enableErrorReminder": ${overrides.enableErrorReminder !== undefined ? overrides.enableErrorReminder : false},
389
-
390
- // ============================================================
391
- // NOTIFICATION MODE SETTINGS (Smart Notification System)
392
- // ============================================================
393
- // Controls how notifications are delivered:
394
- // 'sound-first' - Play sound immediately, TTS reminder after delay (RECOMMENDED)
395
- // 'tts-first' - Speak TTS immediately, no sound
396
- // 'both' - Play sound AND speak TTS immediately
397
- // 'sound-only' - Only play sound, no TTS at all
398
- "notificationMode": "${overrides.notificationMode || 'sound-first'}",
399
-
400
- // ============================================================
401
- // TTS REMINDER SETTINGS (When user doesn't respond to sound)
402
- // ============================================================
403
-
404
- // Enable TTS reminder if user doesn't respond after sound notification
405
- "enableTTSReminder": ${overrides.enableTTSReminder !== undefined ? overrides.enableTTSReminder : true},
406
-
407
- // Delay (in seconds) before TTS reminder fires
408
- // Set globally or per-notification type
409
- "ttsReminderDelaySeconds": ${overrides.ttsReminderDelaySeconds !== undefined ? overrides.ttsReminderDelaySeconds : 30}, // Global default
410
- "idleReminderDelaySeconds": ${overrides.idleReminderDelaySeconds !== undefined ? overrides.idleReminderDelaySeconds : 30}, // For task completion notifications
411
- "permissionReminderDelaySeconds": ${overrides.permissionReminderDelaySeconds !== undefined ? overrides.permissionReminderDelaySeconds : 20}, // For permission requests (more urgent)
412
-
413
- // Follow-up reminders if user STILL doesn't respond after first TTS
414
- "enableFollowUpReminders": ${overrides.enableFollowUpReminders !== undefined ? overrides.enableFollowUpReminders : true},
415
- "maxFollowUpReminders": ${overrides.maxFollowUpReminders !== undefined ? overrides.maxFollowUpReminders : 3}, // Max number of follow-up TTS reminders
416
- "reminderBackoffMultiplier": ${overrides.reminderBackoffMultiplier !== undefined ? overrides.reminderBackoffMultiplier : 1.5}, // Each follow-up waits longer (30s, 45s, 67s...)
417
-
418
- // ============================================================
419
- // TTS ENGINE SELECTION
420
- // ============================================================
421
- // 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI)
422
- // 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
423
- // 'edge' - Good quality neural voices (free, requires: pip install edge-tts)
424
- // 'sapi' - Windows built-in voices (free, offline, robotic)
425
- "ttsEngine": "${overrides.ttsEngine || 'elevenlabs'}",
426
-
427
- // Enable TTS for notifications (falls back to sound files if TTS fails)
428
- "enableTTS": ${overrides.enableTTS !== undefined ? overrides.enableTTS : true},
429
-
430
- // ============================================================
431
- // ELEVENLABS SETTINGS (Best Quality - Anime-like Voices)
432
- // ============================================================
433
- // Get your API key from: https://elevenlabs.io/app/settings/api-keys
434
- // Free tier: 10,000 characters/month
435
- //
436
- // To use ElevenLabs:
437
- // 1. Uncomment elevenLabsApiKey and add your key
438
- // 2. Change ttsEngine above to "elevenlabs"
439
- //
440
- ${overrides.elevenLabsApiKey ? `"elevenLabsApiKey": "${overrides.elevenLabsApiKey}",` : `// "elevenLabsApiKey": "YOUR_API_KEY_HERE",`}
441
-
442
- // Voice ID - Recommended cute/anime-like voices:
443
- // 'cgSgspJ2msm6clMCkdW9' - Jessica (Playful, Bright, Warm) - RECOMMENDED
444
- // 'FGY2WhTYpPnrIDTdsKH5' - Laura (Enthusiast, Quirky)
445
- // 'jsCqWAovK2LkecY7zXl4' - Freya (Expressive, Confident)
446
- // 'EXAVITQu4vr4xnSDxMaL' - Sarah (Soft, Warm)
447
- // Browse more at: https://elevenlabs.io/voice-library
448
- "elevenLabsVoiceId": "${overrides.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9'}",
449
-
450
- // Model: 'eleven_turbo_v2_5' (fast, good), 'eleven_multilingual_v2' (highest quality)
451
- "elevenLabsModel": "${overrides.elevenLabsModel || 'eleven_turbo_v2_5'}",
452
-
453
- // Voice tuning (0.0 to 1.0)
454
- "elevenLabsStability": ${overrides.elevenLabsStability !== undefined ? overrides.elevenLabsStability : 0.5}, // Lower = more expressive, Higher = more consistent
455
- "elevenLabsSimilarity": ${overrides.elevenLabsSimilarity !== undefined ? overrides.elevenLabsSimilarity : 0.75}, // How closely to match the original voice
456
- "elevenLabsStyle": ${overrides.elevenLabsStyle !== undefined ? overrides.elevenLabsStyle : 0.5}, // Style exaggeration (higher = more expressive)
457
-
458
- // ============================================================
459
- // EDGE TTS SETTINGS (Free Neural Voices - Default Engine)
460
- // ============================================================
461
- // Requires: pip install edge-tts
462
-
463
- // Voice options (run 'edge-tts --list-voices' to see all):
464
- // 'en-US-AnaNeural' - Young, cute, cartoon-like (RECOMMENDED)
465
- // 'en-US-JennyNeural' - Friendly, warm
466
- // 'en-US-AriaNeural' - Confident, clear
467
- // 'en-GB-SoniaNeural' - British, friendly
468
- // 'en-AU-NatashaNeural' - Australian, warm
469
- "edgeVoice": "${overrides.edgeVoice || 'en-US-JennyNeural'}",
470
-
471
- // Pitch adjustment: +0Hz to +100Hz (higher = more anime-like)
472
- "edgePitch": "${overrides.edgePitch || '+0Hz'}",
473
-
474
- // Speech rate: -50% to +100%
475
- "edgeRate": "${overrides.edgeRate || '+10%'}",
476
-
477
- // ============================================================
478
- // SAPI SETTINGS (Windows Built-in - Last Resort Fallback)
479
- // ============================================================
480
-
481
- // Voice (run PowerShell to list all installed voices):
482
- // Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | % { $_.VoiceInfo.Name }
483
- //
484
- // Common Windows voices:
485
- // 'Microsoft Zira Desktop' - Female, US English
486
- // 'Microsoft David Desktop' - Male, US English
487
- // 'Microsoft Hazel Desktop' - Female, UK English
488
- "sapiVoice": "${overrides.sapiVoice || 'Microsoft Zira Desktop'}",
489
-
490
- // Speech rate: -10 (slowest) to +10 (fastest), 0 is normal
491
- "sapiRate": ${overrides.sapiRate !== undefined ? overrides.sapiRate : -1},
492
-
493
- // Pitch: 'x-low', 'low', 'medium', 'high', 'x-high'
494
- "sapiPitch": "${overrides.sapiPitch || 'medium'}",
495
-
496
- // Volume: 'silent', 'x-soft', 'soft', 'medium', 'loud', 'x-loud'
497
- "sapiVolume": "${overrides.sapiVolume || 'loud'}",
498
-
499
- // ============================================================
500
- // OPENAI-COMPATIBLE TTS SETTINGS (Kokoro, LocalAI, OpenAI, etc.)
501
- // ============================================================
502
- // Any OpenAI-compatible /v1/audio/speech endpoint.
503
- // Examples: Kokoro, OpenAI, LocalAI, Coqui, AllTalk, etc.
504
- //
505
- // To use OpenAI-compatible TTS:
506
- // 1. Set ttsEngine above to "openai"
507
- // 2. Set openaiTtsEndpoint to your server URL (without /v1/audio/speech)
508
- // 3. Configure voice and model for your server
509
-
510
- // Base URL for your TTS server (e.g., "http://192.168.86.43:8880")
511
- "openaiTtsEndpoint": "${overrides.openaiTtsEndpoint || ''}",
512
-
513
- // API key (leave empty if your server doesn't require auth)
514
- "openaiTtsApiKey": "${overrides.openaiTtsApiKey || ''}",
515
-
516
- // Model name (server-dependent, e.g., "tts-1", "kokoro", "xtts")
517
- "openaiTtsModel": "${overrides.openaiTtsModel || 'tts-1'}",
518
-
519
- // Voice name (server-dependent)
520
- // Kokoro voices: "af_heart", "af_bella", "am_adam", etc.
521
- // OpenAI voices: "alloy", "echo", "fable", "onyx", "nova", "shimmer"
522
- "openaiTtsVoice": "${overrides.openaiTtsVoice || 'alloy'}",
523
-
524
- // Audio format: "mp3", "opus", "aac", "flac", "wav", "pcm"
525
- "openaiTtsFormat": "${overrides.openaiTtsFormat || 'mp3'}",
526
-
527
- // Speech speed: 0.25 to 4.0 (1.0 = normal)
528
- "openaiTtsSpeed": ${overrides.openaiTtsSpeed !== undefined ? overrides.openaiTtsSpeed : 1.0},
529
-
530
- // ============================================================
531
- // INITIAL TTS MESSAGES (Used immediately or after sound)
532
- // These are randomly selected each time for variety
533
- // ============================================================
534
-
535
- // Messages when agent finishes work (task completion)
536
- "idleTTSMessages": ${formatJSON(overrides.idleTTSMessages || [
537
- "All done! Your task has been completed successfully.",
538
- "Hey there! I finished working on your request.",
539
- "Task complete! Ready for your review whenever you are.",
540
- "Good news! Everything is done and ready for you.",
541
- "Finished! Let me know if you need anything else."
542
- ], 4)},
543
-
544
- // Messages for permission requests
545
- "permissionTTSMessages": ${formatJSON(overrides.permissionTTSMessages || [
546
- "Attention please! I need your permission to continue.",
547
- "Hey! Quick approval needed to proceed with the task.",
548
- "Heads up! There is a permission request waiting for you.",
549
- "Excuse me! I need your authorization before I can continue.",
550
- "Permission required! Please review and approve when ready."
551
- ], 4)},
552
-
553
- // Messages for MULTIPLE permission requests (use {count} placeholder)
554
- // Used when several permissions arrive simultaneously
555
- "permissionTTSMessagesMultiple": ${formatJSON(overrides.permissionTTSMessagesMultiple || [
556
- "Attention please! There are {count} permission requests waiting for your approval.",
557
- "Hey! {count} permissions need your approval to continue.",
558
- "Heads up! You have {count} pending permission requests.",
559
- "Excuse me! I need your authorization for {count} different actions.",
560
- "{count} permissions required! Please review and approve when ready."
561
- ], 4)},
562
-
563
- // ============================================================
564
- // TTS REMINDER MESSAGES (More urgent - used after delay if no response)
565
- // These are more personalized and urgent to get user attention
566
- // ============================================================
567
-
568
- // Reminder messages when agent finished but user hasn't responded
569
- "idleReminderTTSMessages": ${formatJSON(overrides.idleReminderTTSMessages || [
570
- "Hey, are you still there? Your task has been waiting for review.",
571
- "Just a gentle reminder - I finished your request a while ago!",
572
- "Hello? I completed your task. Please take a look when you can.",
573
- "Still waiting for you! The work is done and ready for review.",
574
- "Knock knock! Your completed task is patiently waiting for you."
575
- ], 4)},
576
-
577
- // Reminder messages when permission still needed
578
- "permissionReminderTTSMessages": ${formatJSON(overrides.permissionReminderTTSMessages || [
579
- "Hey! I still need your permission to continue. Please respond!",
580
- "Reminder: There is a pending permission request. I cannot proceed without you.",
581
- "Hello? I am waiting for your approval. This is getting urgent!",
582
- "Please check your screen! I really need your permission to move forward.",
583
- "Still waiting for authorization! The task is on hold until you respond."
584
- ], 4)},
585
-
586
- // Reminder messages for MULTIPLE permissions (use {count} placeholder)
587
- "permissionReminderTTSMessagesMultiple": ${formatJSON(overrides.permissionReminderTTSMessagesMultiple || [
588
- "Hey! I still need your approval for {count} permissions. Please respond!",
589
- "Reminder: There are {count} pending permission requests. I cannot proceed without you.",
590
- "Hello? I am waiting for your approval on {count} items. This is getting urgent!",
591
- "Please check your screen! {count} permissions are waiting for your response.",
592
- "Still waiting for authorization on {count} requests! The task is on hold."
593
- ], 4)},
594
-
595
- // ============================================================
596
- // PERMISSION BATCHING (Multiple permissions at once)
597
- // ============================================================
598
- // When multiple permissions arrive simultaneously, batch them into one notification
599
- // This prevents overlapping sounds when 5+ permissions come at once
600
-
601
- // Batch window (ms) - how long to wait for more permissions before notifying
602
- "permissionBatchWindowMs": ${overrides.permissionBatchWindowMs !== undefined ? overrides.permissionBatchWindowMs : 800},
603
-
604
- // ============================================================
605
- // QUESTION TOOL SETTINGS (SDK v1.1.7+ - Agent asking user questions)
606
- // ============================================================
607
- // The "question" tool allows the LLM to ask users questions during execution.
608
- // This is useful for gathering preferences, clarifying instructions, or getting
609
- // decisions on implementation choices.
610
-
611
- // Messages when agent asks user a question
612
- "questionTTSMessages": ${formatJSON(overrides.questionTTSMessages || [
613
- "Hey! I have a question for you. Please check your screen.",
614
- "Attention! I need your input to continue.",
615
- "Quick question! Please take a look when you have a moment.",
616
- "I need some clarification. Could you please respond?",
617
- "Question time! Your input is needed to proceed."
618
- ], 4)},
619
-
620
- // Messages for MULTIPLE questions (use {count} placeholder)
621
- "questionTTSMessagesMultiple": ${formatJSON(overrides.questionTTSMessagesMultiple || [
622
- "Hey! I have {count} questions for you. Please check your screen.",
623
- "Attention! I need your input on {count} items to continue.",
624
- "{count} questions need your attention. Please take a look!",
625
- "I need some clarifications. There are {count} questions waiting for you.",
626
- "Question time! {count} questions need your response to proceed."
627
- ], 4)},
628
-
629
- // Reminder messages for questions (more urgent - used after delay)
630
- "questionReminderTTSMessages": ${formatJSON(overrides.questionReminderTTSMessages || [
631
- "Hey! I am still waiting for your answer. Please check the questions!",
632
- "Reminder: There is a question waiting for your response.",
633
- "Hello? I need your input to continue. Please respond when you can.",
634
- "Still waiting for your answer! The task is on hold.",
635
- "Your input is needed! Please check the pending question."
636
- ], 4)},
637
-
638
- // Reminder messages for MULTIPLE questions (use {count} placeholder)
639
- "questionReminderTTSMessagesMultiple": ${formatJSON(overrides.questionReminderTTSMessagesMultiple || [
640
- "Hey! I am still waiting for answers to {count} questions. Please respond!",
641
- "Reminder: There are {count} questions waiting for your response.",
642
- "Hello? I need your input on {count} items. Please respond when you can.",
643
- "Still waiting for your answers on {count} questions! The task is on hold.",
644
- "Your input is needed! {count} questions are pending your response."
645
- ], 4)},
646
-
647
- // Delay (in seconds) before question reminder fires
648
- "questionReminderDelaySeconds": ${overrides.questionReminderDelaySeconds !== undefined ? overrides.questionReminderDelaySeconds : 25},
649
-
650
- // Question batch window (ms) - how long to wait for more questions before notifying
651
- "questionBatchWindowMs": ${overrides.questionBatchWindowMs !== undefined ? overrides.questionBatchWindowMs : 800},
652
-
653
- // ============================================================
654
- // ERROR NOTIFICATION SETTINGS (Session Errors)
655
- // ============================================================
656
- // Notify users when the agent encounters an error during execution.
657
- // Error notifications use more urgent messaging to get user attention.
658
-
659
- // Messages when agent encounters an error
660
- "errorTTSMessages": ${formatJSON(overrides.errorTTSMessages || [
661
- "Oops! Something went wrong. Please check for errors.",
662
- "Alert! The agent encountered an error and needs your attention.",
663
- "Error detected! Please review the issue when you can.",
664
- "Houston, we have a problem! An error occurred during the task.",
665
- "Heads up! There was an error that requires your attention."
666
- ], 4)},
667
-
668
- // Messages for MULTIPLE errors (use {count} placeholder)
669
- "errorTTSMessagesMultiple": ${formatJSON(overrides.errorTTSMessagesMultiple || [
670
- "Oops! There are {count} errors that need your attention.",
671
- "Alert! The agent encountered {count} errors. Please review.",
672
- "{count} errors detected! Please check when you can.",
673
- "Houston, we have {count} problems! Multiple errors occurred.",
674
- "Heads up! {count} errors require your attention."
675
- ], 4)},
676
-
677
- // Reminder messages for errors (more urgent - used after delay)
678
- "errorReminderTTSMessages": ${formatJSON(overrides.errorReminderTTSMessages || [
679
- "Hey! There's still an error waiting for your attention.",
680
- "Reminder: An error occurred and hasn't been addressed yet.",
681
- "The agent is stuck! Please check the error when you can.",
682
- "Still waiting! That error needs your attention.",
683
- "Don't forget! There's an unresolved error in your session."
684
- ], 4)},
685
-
686
- // Reminder messages for MULTIPLE errors (use {count} placeholder)
687
- "errorReminderTTSMessagesMultiple": ${formatJSON(overrides.errorReminderTTSMessagesMultiple || [
688
- "Hey! There are still {count} errors waiting for your attention.",
689
- "Reminder: {count} errors occurred and haven't been addressed yet.",
690
- "The agent is stuck! Please check the {count} errors when you can.",
691
- "Still waiting! {count} errors need your attention.",
692
- "Don't forget! There are {count} unresolved errors in your session."
693
- ], 4)},
694
-
695
- // Delay (in seconds) before error reminder fires (shorter than idle for urgency)
696
- "errorReminderDelaySeconds": ${overrides.errorReminderDelaySeconds !== undefined ? overrides.errorReminderDelaySeconds : 20},
697
-
698
- // ============================================================
699
- // AI MESSAGE GENERATION (OpenAI-Compatible Endpoints)
700
- // ============================================================
701
- // Use a local/self-hosted AI to generate dynamic notification messages
702
- // instead of using preset static messages. The AI generates the text,
703
- // which is then spoken by your configured TTS engine (ElevenLabs, Edge, etc.)
704
- //
705
- // Supports: Ollama, LM Studio, LocalAI, vLLM, llama.cpp, Jan.ai, and any
706
- // OpenAI-compatible endpoint. You provide your own endpoint URL and API key.
707
-
708
- // Enable AI-generated messages (experimental feature)
709
- "enableAIMessages": ${overrides.enableAIMessages !== undefined ? overrides.enableAIMessages : false},
710
-
711
- // Your AI server endpoint URL (e.g., Ollama: http://localhost:11434/v1)
712
- // Common endpoints:
713
- // Ollama: http://localhost:11434/v1
714
- // LM Studio: http://localhost:1234/v1
715
- // LocalAI: http://localhost:8080/v1
716
- // vLLM: http://localhost:8000/v1
717
- // Jan.ai: http://localhost:1337/v1
718
- "aiEndpoint": "${overrides.aiEndpoint || 'http://localhost:11434/v1'}",
719
-
720
- // Model name to use (depends on what's loaded in your AI server)
721
- // Examples: "llama3", "mistral", "phi3", "gemma2", "qwen2"
722
- "aiModel": "${overrides.aiModel || 'llama3'}",
723
-
724
- // API key for your AI server (leave empty for Ollama/LM Studio/LocalAI)
725
- // Only needed if your server requires authentication
726
- "aiApiKey": "${overrides.aiApiKey || ''}",
727
-
728
- // Request timeout in milliseconds (local AI can be slow on first request)
729
- "aiTimeout": ${overrides.aiTimeout !== undefined ? overrides.aiTimeout : 15000},
730
-
731
- // Fallback to static preset messages if AI generation fails
732
- "aiFallbackToStatic": ${overrides.aiFallbackToStatic !== undefined ? overrides.aiFallbackToStatic : true},
733
-
734
- // Enable context-aware AI messages (includes project name, task title, and change summary)
735
- // When enabled, AI-generated notifications will include relevant context like:
736
- // - Project name (e.g., "Your work on MyProject is complete!")
737
- // - Task/session title if available
738
- // - Change summary (files modified, lines added/deleted)
739
- // Disabled by default - enable this for more personalized notifications
740
- "enableContextAwareAI": ${overrides.enableContextAwareAI !== undefined ? overrides.enableContextAwareAI : false},
741
-
742
- // Custom prompts for each notification type
743
- // The AI will generate a short message based on these prompts
744
- // Keep prompts concise - they're sent with each notification
745
- "aiPrompts": ${formatJSON(overrides.aiPrompts || {
746
- "idle": "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.",
747
- "permission": "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.",
748
- "question": "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.",
749
- "error": "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.",
750
- "idleReminder": "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.",
751
- "permissionReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.",
752
- "questionReminder": "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.",
753
- "errorReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes."
754
- }, 4)},
755
-
756
- // ============================================================
757
- // SOUND FILES (For immediate notifications)
758
- // These are played first before TTS reminder kicks in
759
- // ============================================================
760
- // Paths are relative to ~/.config/opencode/ directory
761
- // Sound files are automatically copied here on first run
762
- // You can replace with your own custom MP3/WAV files
763
-
764
- "idleSound": "${overrides.idleSound || 'assets/Soft-high-tech-notification-sound-effect.mp3'}",
765
- "permissionSound": "${overrides.permissionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
766
- "questionSound": "${overrides.questionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
767
- "errorSound": "${overrides.errorSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
768
-
769
- // ============================================================
770
- // GENERAL SETTINGS
771
- // ============================================================
772
-
773
- // Wake monitor from sleep when notifying (Windows/macOS)
774
- "wakeMonitor": ${overrides.wakeMonitor !== undefined ? overrides.wakeMonitor : true},
775
-
776
- // Force system volume up if below threshold
777
- "forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : false},
778
-
779
- // Volume threshold (0-100): force volume if current level is below this
780
- "volumeThreshold": ${overrides.volumeThreshold !== undefined ? overrides.volumeThreshold : 50},
781
-
782
- // Show TUI toast notifications in OpenCode terminal
783
- "enableToast": ${overrides.enableToast !== undefined ? overrides.enableToast : true},
784
-
785
- // Enable audio notifications (sound files and TTS)
786
- "enableSound": ${overrides.enableSound !== undefined ? overrides.enableSound : true},
787
-
788
- // ============================================================
789
- // DESKTOP NOTIFICATION SETTINGS
790
- // ============================================================
791
- // Native desktop notifications (Windows Toast, macOS Notification Center, Linux notify-send)
792
- // These appear as system notifications alongside sound and TTS.
793
- //
794
- // Note: On Linux, you may need to install libnotify-bin:
795
- // Ubuntu/Debian: sudo apt install libnotify-bin
796
- // Fedora: sudo dnf install libnotify
797
- // Arch: sudo pacman -S libnotify
798
-
799
- // Enable native desktop notifications
800
- "enableDesktopNotification": ${overrides.enableDesktopNotification !== undefined ? overrides.enableDesktopNotification : true},
801
-
802
- // How long the notification stays on screen (in seconds)
803
- // Note: Some platforms may ignore this (especially Windows 10+)
804
- "desktopNotificationTimeout": ${overrides.desktopNotificationTimeout !== undefined ? overrides.desktopNotificationTimeout : 5},
805
-
806
- // Include the project name in notification titles for easier identification
807
- // Example: "OpenCode - MyProject" instead of just "OpenCode"
808
- "showProjectInNotification": ${overrides.showProjectInNotification !== undefined ? overrides.showProjectInNotification : true},
809
-
810
- // ============================================================
811
- // FOCUS DETECTION SETTINGS
812
- // ============================================================
813
- // Suppress notifications when you're actively looking at the terminal.
814
- // This prevents notifications from interrupting you when you're already
815
- // paying attention to the OpenCode terminal.
816
- //
817
- // PLATFORM SUPPORT:
818
- // macOS: Full support - Uses AppleScript to detect frontmost application
819
- // Windows: Not supported - No reliable API available
820
- // Linux: Not supported - Varies by desktop environment
821
- //
822
- // When focus detection is not supported on your platform, notifications
823
- // will always be sent (fail-open behavior).
824
-
825
- // Suppress sound and desktop notifications when terminal is focused
826
- // TTS reminders are still allowed (user might step away after task completes)
827
- "suppressWhenFocused": ${overrides.suppressWhenFocused !== undefined ? overrides.suppressWhenFocused : true},
828
-
829
- // Override focus detection: always send notifications even when terminal is focused
830
- // Set to true to disable focus-based suppression entirely
831
- "alwaysNotify": ${overrides.alwaysNotify !== undefined ? overrides.alwaysNotify : false},
832
-
833
- // ============================================================
834
- // WEBHOOK NOTIFICATION SETTINGS (Discord/Generic)
835
- // ============================================================
836
- // Send notifications to a Discord webhook or any compatible endpoint.
837
- // This allows you to receive notifications on your phone or other devices.
838
-
839
- // Enable webhook notifications
840
- "enableWebhook": ${overrides.enableWebhook !== undefined ? overrides.enableWebhook : false},
841
-
842
- // Webhook URL (e.g., https://discord.com/api/webhooks/...)
843
- "webhookUrl": "${overrides.webhookUrl || ''}",
844
-
845
- // Username to show in the webhook message
846
- "webhookUsername": "${overrides.webhookUsername || 'OpenCode Notify'}",
847
-
848
- // Events that should trigger a webhook notification
849
- // Options: "idle", "permission", "error", "question"
850
- "webhookEvents": ${formatJSON(overrides.webhookEvents || ["idle", "permission", "error", "question"], 4)},
851
-
852
- // Mention @everyone on permission requests (Discord only)
853
- "webhookMentionOnPermission": ${overrides.webhookMentionOnPermission !== undefined ? overrides.webhookMentionOnPermission : false},
854
-
855
- // ============================================================
856
- // SOUND THEME SETTINGS (Themed Sound Packs)
857
- // ============================================================
858
- // Configure a directory containing custom sound files for notifications.
859
- // This allows you to use themed sound packs (e.g., Warcraft, StarCraft, etc.)
860
- //
861
- // Directory structure should contain:
862
- // /path/to/theme/idle/ - Sounds for task completion
863
- // /path/to/theme/permission/ - Sounds for permission requests
864
- // /path/to/theme/error/ - Sounds for agent errors
865
- // /path/to/theme/question/ - Sounds for agent questions
866
- //
867
- // If a specific event folder is missing, it falls back to default sounds.
868
-
869
- // Path to your custom sound theme directory (absolute path recommended)
870
- "soundThemeDir": "${overrides.soundThemeDir || ''}",
871
-
872
- // Pick a random sound from the appropriate theme folder for each notification
873
- "randomizeSoundFromTheme": ${overrides.randomizeSoundFromTheme !== undefined ? overrides.randomizeSoundFromTheme : true},
874
-
875
- // ============================================================
876
- // PER-PROJECT SOUND SETTINGS
877
- // ============================================================
878
- // Assign a unique notification sound to each project based on its path.
879
- // This helps you distinguish which project is notifying you when working
880
- // on multiple tasks simultaneously.
881
- //
882
- // Note: Requires sounds named 'ding1.mp3' through 'ding6.mp3' in your
883
- // assets/ folder. If disabled, default sound files are used.
884
-
885
- // Enable unique sounds per project
886
- "perProjectSounds": ${overrides.perProjectSounds !== undefined ? overrides.perProjectSounds : false},
887
-
888
- // Seed value to change sound assignments (0-999)
889
- "projectSoundSeed": ${overrides.projectSoundSeed !== undefined ? overrides.projectSoundSeed : 0},
890
-
891
- // Consider monitor asleep after this many seconds of inactivity (Windows only)
892
- "idleThresholdSeconds": ${overrides.idleThresholdSeconds !== undefined ? overrides.idleThresholdSeconds : 60},
893
-
894
- // Enable debug logging to ~/.config/opencode/logs/smart-voice-notify-debug.log
895
- // The logs folder is created automatically when debug logging is enabled
896
- // Useful for troubleshooting notification issues
897
- "debugLog": ${overrides.debugLog !== undefined ? overrides.debugLog : false}
898
- }`;
899
- };
900
-
901
- /**
902
- * Copy bundled assets (sound files) to the OpenCode config directory.
903
- * @param {string} configDir - The OpenCode config directory path
904
- */
905
- const copyBundledAssets = (configDir) => {
906
- try {
907
- const pluginDir = getPluginDir();
908
- const sourceAssetsDir = path.join(pluginDir, 'assets');
909
- const targetAssetsDir = path.join(configDir, 'assets');
910
-
911
- // Check if source assets exist (they should be bundled with the plugin)
912
- if (!fs.existsSync(sourceAssetsDir)) {
913
- return; // No bundled assets to copy
914
- }
915
-
916
- // Create target assets directory if it doesn't exist
917
- if (!fs.existsSync(targetAssetsDir)) {
918
- fs.mkdirSync(targetAssetsDir, { recursive: true });
919
- }
920
-
921
- // Copy each asset file if it doesn't already exist in target
922
- const assetFiles = fs.readdirSync(sourceAssetsDir);
923
- for (const file of assetFiles) {
924
- const sourcePath = path.join(sourceAssetsDir, file);
925
- const targetPath = path.join(targetAssetsDir, file);
926
-
927
- // Only copy if target doesn't exist (don't overwrite user customizations)
928
- if (!fs.existsSync(targetPath) && fs.statSync(sourcePath).isFile()) {
929
- fs.copyFileSync(sourcePath, targetPath);
930
- }
931
- }
932
- } catch (error) {
933
- // Silently fail - assets are optional
934
- }
935
- };
936
-
937
- /**
938
- * Loads a configuration file from the OpenCode config directory.
939
- * If the file doesn't exist, creates a default config file with full documentation.
940
- * If the file exists, performs smart merging to add new fields without overwriting user values.
941
- *
942
- * IMPORTANT: User values are NEVER overwritten. Only new fields from plugin updates are added.
943
- *
944
- * @param {string} name - Name of the config file (without .jsonc extension)
945
- * @param {object} defaults - Default values if file doesn't exist or is invalid
946
- * @returns {object}
947
- */
948
- export const loadConfig = (name, defaults = {}) => {
949
- const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
950
- const filePath = path.join(configDir, `${name}.jsonc`);
951
-
952
- // Get current version from package.json
953
- const pluginDir = getPluginDir();
954
- const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8'));
955
- const currentVersion = pkg.version;
956
-
957
- // Get default config object with current version early so it can be used for peeking
958
- const defaultConfig = getDefaultConfigObject();
959
- defaultConfig._configVersion = currentVersion;
960
-
961
- // Always ensure bundled assets are present
962
- copyBundledAssets(configDir);
963
-
964
- // Try to load existing config
965
- let existingConfig = null;
966
- if (fs.existsSync(filePath)) {
967
- try {
968
- const content = fs.readFileSync(filePath, 'utf-8');
969
- existingConfig = parseJSONC(content);
970
- } catch (error) {
971
- // If file is invalid JSONC, we'll use defaults for this run but NOT overwrite the user's file
972
- // This prevents accidental loss of configuration due to a simple syntax error
973
- debugLogToFile(`Warning: Config file at ${filePath} is invalid (${error.message}). Using default values for now. Please check your config for syntax errors.`, configDir);
974
- existingConfig = null; // Forces CASE 1 logic but we'll modify it to avoid writing
975
-
976
- // SMART PEEK: Even if parsing fails, try to see if "enabled" field is set to false/disabled
977
- // to respect the user's intent to disable the plugin even with syntax errors.
978
- try {
979
- const rawContent = fs.readFileSync(filePath, 'utf-8');
980
- // Match both boolean and string values for "enabled"
981
- const enabledMatch = rawContent.match(/"enabled"\s*:\s*(false|true|"disabled"|"enabled"|'disabled'|'enabled')/i);
982
- if (enabledMatch) {
983
- const val = enabledMatch[1].replace(/["']/g, '').toLowerCase();
984
- const isActuallyEnabled = (val === 'true' || val === 'enabled');
985
-
986
- // Inject into defaults and defaultConfig so it's picked up
987
- defaults.enabled = isActuallyEnabled;
988
- defaultConfig.enabled = isActuallyEnabled;
989
- debugLogToFile(`Detected 'enabled: ${isActuallyEnabled}' via emergency regex peek (syntax error in file)`, configDir);
990
- }
991
- } catch (e) {
992
- // Peek failed, just proceed with CASE 1
993
- }
994
- }
995
-
996
- }
997
-
998
- // CASE 1: No existing config (missing or invalid)
999
- if (!existingConfig) {
1000
-
1001
- try {
1002
- // Ensure config directory exists
1003
- if (!fs.existsSync(configDir)) {
1004
- fs.mkdirSync(configDir, { recursive: true });
1005
- }
1006
-
1007
- // ONLY write a fresh config file if it doesn't exist at all.
1008
- // If it exists but was invalid, we already logged a warning and we'll just return defaults.
1009
- if (!fs.existsSync(filePath)) {
1010
- // Generate new config file with all documentation comments
1011
- const newConfigContent = generateDefaultConfig({}, currentVersion);
1012
- fs.writeFileSync(filePath, newConfigContent, 'utf-8');
1013
- debugLogToFile(`Initialized default config at ${filePath}`, configDir);
1014
- }
1015
-
1016
- // Return the default config merged with any passed defaults
1017
- return { ...defaults, ...defaultConfig };
1018
- } catch (error) {
1019
- // If creation fails, return defaults
1020
- return { ...defaults, ...defaultConfig };
1021
- }
1022
- }
1023
-
1024
-
1025
- // CASE 2: Existing config - smart merge to add new fields only
1026
- // Find what new fields need to be added (for logging)
1027
- const newFields = findNewFields(defaultConfig, existingConfig);
1028
-
1029
- // Deep merge: user values preserved, only new fields added from defaults
1030
- const mergedConfig = deepMerge(defaultConfig, existingConfig);
1031
-
1032
- // Update version in merged config
1033
- mergedConfig._configVersion = currentVersion;
1034
-
1035
- // Only write back if there are new fields to add OR version changed
1036
- const versionChanged = existingConfig._configVersion !== currentVersion;
1037
-
1038
- if (newFields.length > 0 || versionChanged) {
1039
- try {
1040
- // Regenerate the config file with full documentation comments
1041
- // Pass the merged config so user values are preserved in the output
1042
- const newConfigContent = generateDefaultConfig(mergedConfig, currentVersion);
1043
- fs.writeFileSync(filePath, newConfigContent, 'utf-8');
1044
-
1045
- if (newFields.length > 0) {
1046
- debugLogToFile(`Added ${newFields.length} new config field(s): ${newFields.join(', ')}`, configDir);
1047
- }
1048
- if (versionChanged) {
1049
- debugLogToFile(`Config version updated to ${currentVersion}`, configDir);
1050
- }
1051
- } catch (error) {
1052
- // If write fails, still return the merged config (just won't persist new fields)
1053
- debugLogToFile(`Warning: Could not update config file: ${error.message}`, configDir);
1054
- }
1055
- }
1056
-
1057
- return { ...defaults, ...mergedConfig };
1058
- };
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ /**
7
+ * Debug logging to file (no console output).
8
+ * Logs are written to ~/.config/opencode/logs/smart-voice-notify-debug.log
9
+ * @param {string} message - Message to log
10
+ * @param {string} configDir - Config directory path
11
+ */
12
+ const debugLogToFile = (message, configDir) => {
13
+ try {
14
+ const logsDir = path.join(configDir, 'logs');
15
+ if (!fs.existsSync(logsDir)) {
16
+ fs.mkdirSync(logsDir, { recursive: true });
17
+ }
18
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
19
+ const timestamp = new Date().toISOString();
20
+ fs.appendFileSync(logFile, `[${timestamp}] [config] ${message}\n`);
21
+ } catch (e) {
22
+ // Silently fail - logging should never break the plugin
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Basic JSONC parser that strips single-line and multi-line comments,
28
+ * and handles trailing commas (which Prettier often adds).
29
+ * @param {string} jsonc
30
+ * @returns {any}
31
+ */
32
+ export const parseJSONC = (jsonc) => {
33
+ // Step 1: Strip comments while preserving strings
34
+ // This regex matches strings (handling escaped quotes) or comments
35
+ // If it's a comment, we replace it with empty string
36
+ let stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m);
37
+
38
+ // Step 2: Strip trailing commas (e.g. [1, 2,] or {"a":1,})
39
+ // This helps when formatters like Prettier are used
40
+ stripped = stripped.replace(/,(\s*[\]}])/g, '$1');
41
+
42
+ // Step 3: Handle literal control characters that might be present
43
+ // JSON.parse fails on literal control characters (U+0000 to U+001F).
44
+ // Some are allowed as whitespace (space, tab, newline, cr), but literal
45
+ // tabs or newlines INSIDE strings are strictly forbidden.
46
+ // We'll strip most of them, but preserve allowed whitespace outside strings.
47
+ // A safer approach for user-edited files is to remove characters that
48
+ // definitely shouldn't be there.
49
+ stripped = stripped.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
50
+
51
+ return JSON.parse(stripped);
52
+ };
53
+
54
+ /**
55
+ * Helper to format JSON values for the template.
56
+ * @param {any} val
57
+ * @param {number} indent
58
+ * @returns {string}
59
+ */
60
+ export const formatJSON = (val, indent = 0) => {
61
+ const json = JSON.stringify(val, null, 4);
62
+ return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json;
63
+ };
64
+
65
+ /**
66
+ * Deep merge two objects. User values take precedence over defaults.
67
+ * - For objects: recursively merge, adding new keys from defaults
68
+ * - For arrays: user's array completely replaces default (no merge)
69
+ * - For primitives: user's value takes precedence if it exists
70
+ *
71
+ * @param {object} defaults - The default configuration object
72
+ * @param {object} user - The user's existing configuration object
73
+ * @returns {object} Merged configuration with user values preserved
74
+ */
75
+ export const deepMerge = (defaults, user) => {
76
+ // If user value doesn't exist, use default
77
+ if (user === undefined || user === null) {
78
+ return defaults;
79
+ }
80
+
81
+ // If either is not an object (or is array), user value wins
82
+ if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
83
+ return user;
84
+ }
85
+ if (typeof user !== 'object' || user === null || Array.isArray(user)) {
86
+ return user;
87
+ }
88
+
89
+ // Both are objects - merge them
90
+ const result = { ...user };
91
+
92
+ for (const key of Object.keys(defaults)) {
93
+ if (!(key in user)) {
94
+ // Key doesn't exist in user config - add it from defaults
95
+ result[key] = defaults[key];
96
+ } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
97
+ // Both have this key and it's an object - recurse
98
+ result[key] = deepMerge(defaults[key], user[key]);
99
+ }
100
+ // else: user has this key and it's not an object to merge - keep user's value
101
+ }
102
+
103
+ return result;
104
+ };
105
+
106
+ /**
107
+ * Get the default configuration object.
108
+ * This is the source of truth for all default values.
109
+ * @returns {object} Default configuration object
110
+ */
111
+ export const getDefaultConfigObject = () => ({
112
+
113
+ _configVersion: null, // Will be set by caller
114
+ enabled: true,
115
+ notificationMode: 'sound-first',
116
+ enableTTSReminder: true,
117
+ enableIdleNotification: true,
118
+ enablePermissionNotification: true,
119
+ enableQuestionNotification: true,
120
+ enableErrorNotification: false,
121
+ enableIdleReminder: true,
122
+ enablePermissionReminder: true,
123
+ enableQuestionReminder: true,
124
+ enableErrorReminder: false,
125
+ ttsReminderDelaySeconds: 30,
126
+ idleReminderDelaySeconds: 30,
127
+ permissionReminderDelaySeconds: 20,
128
+ enableFollowUpReminders: true,
129
+ maxFollowUpReminders: 3,
130
+ reminderBackoffMultiplier: 1.5,
131
+ ttsEngine: 'elevenlabs',
132
+ enableTTS: true,
133
+ // elevenLabsApiKey is intentionally omitted - users must set it
134
+ elevenLabsVoiceId: 'cgSgspJ2msm6clMCkdW9',
135
+ elevenLabsModel: 'eleven_turbo_v2_5',
136
+ elevenLabsStability: 0.5,
137
+ elevenLabsSimilarity: 0.75,
138
+ elevenLabsStyle: 0.5,
139
+ edgeVoice: 'en-US-JennyNeural',
140
+ edgePitch: '+0Hz',
141
+ edgeRate: '+10%',
142
+ sapiVoice: 'Microsoft Zira Desktop',
143
+ sapiRate: -1,
144
+ sapiPitch: 'medium',
145
+ sapiVolume: 'loud',
146
+ openaiTtsEndpoint: '',
147
+ openaiTtsApiKey: '',
148
+ openaiTtsModel: 'tts-1',
149
+ openaiTtsVoice: 'alloy',
150
+ openaiTtsFormat: 'mp3',
151
+ openaiTtsSpeed: 1.0,
152
+ idleTTSMessages: [
153
+ "All done! Your task has been completed successfully.",
154
+ "Hey there! I finished working on your request.",
155
+ "Task complete! Ready for your review whenever you are.",
156
+ "Good news! Everything is done and ready for you.",
157
+ "Finished! Let me know if you need anything else."
158
+ ],
159
+ permissionTTSMessages: [
160
+ "Attention please! I need your permission to continue.",
161
+ "Hey! Quick approval needed to proceed with the task.",
162
+ "Heads up! There is a permission request waiting for you.",
163
+ "Excuse me! I need your authorization before I can continue.",
164
+ "Permission required! Please review and approve when ready."
165
+ ],
166
+ permissionTTSMessagesMultiple: [
167
+ "Attention please! There are {count} permission requests waiting for your approval.",
168
+ "Hey! {count} permissions need your approval to continue.",
169
+ "Heads up! You have {count} pending permission requests.",
170
+ "Excuse me! I need your authorization for {count} different actions.",
171
+ "{count} permissions required! Please review and approve when ready."
172
+ ],
173
+ idleReminderTTSMessages: [
174
+ "Hey, are you still there? Your task has been waiting for review.",
175
+ "Just a gentle reminder - I finished your request a while ago!",
176
+ "Hello? I completed your task. Please take a look when you can.",
177
+ "Still waiting for you! The work is done and ready for review.",
178
+ "Knock knock! Your completed task is patiently waiting for you."
179
+ ],
180
+ permissionReminderTTSMessages: [
181
+ "Hey! I still need your permission to continue. Please respond!",
182
+ "Reminder: There is a pending permission request. I cannot proceed without you.",
183
+ "Hello? I am waiting for your approval. This is getting urgent!",
184
+ "Please check your screen! I really need your permission to move forward.",
185
+ "Still waiting for authorization! The task is on hold until you respond."
186
+ ],
187
+ permissionReminderTTSMessagesMultiple: [
188
+ "Hey! I still need your approval for {count} permissions. Please respond!",
189
+ "Reminder: There are {count} pending permission requests. I cannot proceed without you.",
190
+ "Hello? I am waiting for your approval on {count} items. This is getting urgent!",
191
+ "Please check your screen! {count} permissions are waiting for your response.",
192
+ "Still waiting for authorization on {count} requests! The task is on hold."
193
+ ],
194
+ permissionBatchWindowMs: 800,
195
+ questionTTSMessages: [
196
+ "Hey! I have a question for you. Please check your screen.",
197
+ "Attention! I need your input to continue.",
198
+ "Quick question! Please take a look when you have a moment.",
199
+ "I need some clarification. Could you please respond?",
200
+ "Question time! Your input is needed to proceed."
201
+ ],
202
+ questionTTSMessagesMultiple: [
203
+ "Hey! I have {count} questions for you. Please check your screen.",
204
+ "Attention! I need your input on {count} items to continue.",
205
+ "{count} questions need your attention. Please take a look!",
206
+ "I need some clarifications. There are {count} questions waiting for you.",
207
+ "Question time! {count} questions need your response to proceed."
208
+ ],
209
+ questionReminderTTSMessages: [
210
+ "Hey! I am still waiting for your answer. Please check the questions!",
211
+ "Reminder: There is a question waiting for your response.",
212
+ "Hello? I need your input to continue. Please respond when you can.",
213
+ "Still waiting for your answer! The task is on hold.",
214
+ "Your input is needed! Please check the pending question."
215
+ ],
216
+ questionReminderTTSMessagesMultiple: [
217
+ "Hey! I am still waiting for answers to {count} questions. Please respond!",
218
+ "Reminder: There are {count} questions waiting for your response.",
219
+ "Hello? I need your input on {count} items. Please respond when you can.",
220
+ "Still waiting for your answers on {count} questions! The task is on hold.",
221
+ "Your input is needed! {count} questions are pending your response."
222
+ ],
223
+ questionReminderDelaySeconds: 25,
224
+ questionBatchWindowMs: 800,
225
+ errorTTSMessages: [
226
+ "Oops! Something went wrong. Please check for errors.",
227
+ "Alert! The agent encountered an error and needs your attention.",
228
+ "Error detected! Please review the issue when you can.",
229
+ "Houston, we have a problem! An error occurred during the task.",
230
+ "Heads up! There was an error that requires your attention."
231
+ ],
232
+ errorTTSMessagesMultiple: [
233
+ "Oops! There are {count} errors that need your attention.",
234
+ "Alert! The agent encountered {count} errors. Please review.",
235
+ "{count} errors detected! Please check when you can.",
236
+ "Houston, we have {count} problems! Multiple errors occurred.",
237
+ "Heads up! {count} errors require your attention."
238
+ ],
239
+ errorReminderTTSMessages: [
240
+ "Hey! There's still an error waiting for your attention.",
241
+ "Reminder: An error occurred and hasn't been addressed yet.",
242
+ "The agent is stuck! Please check the error when you can.",
243
+ "Still waiting! That error needs your attention.",
244
+ "Don't forget! There's an unresolved error in your session."
245
+ ],
246
+ errorReminderTTSMessagesMultiple: [
247
+ "Hey! There are still {count} errors waiting for your attention.",
248
+ "Reminder: {count} errors occurred and haven't been addressed yet.",
249
+ "The agent is stuck! Please check the {count} errors when you can.",
250
+ "Still waiting! {count} errors need your attention.",
251
+ "Don't forget! There are {count} unresolved errors in your session."
252
+ ],
253
+ errorReminderDelaySeconds: 20,
254
+ enableAIMessages: false,
255
+ aiEndpoint: 'http://localhost:11434/v1',
256
+ aiModel: 'llama3',
257
+ aiApiKey: '',
258
+ aiTimeout: 15000,
259
+ aiFallbackToStatic: true,
260
+ enableContextAwareAI: false,
261
+ aiPrompts: {
262
+ idle: "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.",
263
+ permission: "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.",
264
+ question: "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.",
265
+ error: "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.",
266
+ idleReminder: "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.",
267
+ permissionReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.",
268
+ questionReminder: "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.",
269
+ errorReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes."
270
+ },
271
+ idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3',
272
+ permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
273
+ questionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
274
+ errorSound: 'assets/Machine-alert-beep-sound-effect.mp3',
275
+ wakeMonitor: true,
276
+ forceVolume: false,
277
+ volumeThreshold: 50,
278
+ enableToast: true,
279
+ enableSound: true,
280
+ enableDesktopNotification: true,
281
+ desktopNotificationTimeout: 5,
282
+ showProjectInNotification: true,
283
+ suppressWhenFocused: true,
284
+ alwaysNotify: false,
285
+ enableWebhook: false,
286
+ webhookUrl: "",
287
+ webhookUsername: "OpenCode Notify",
288
+ webhookEvents: ["idle", "permission", "error", "question"],
289
+ webhookMentionOnPermission: false,
290
+ soundThemeDir: "",
291
+ randomizeSoundFromTheme: true,
292
+ perProjectSounds: false,
293
+ projectSoundSeed: 0,
294
+ idleThresholdSeconds: 60,
295
+ debugLog: false
296
+ });
297
+
298
+ /**
299
+ * Find new fields that exist in defaults but not in user config.
300
+ * Used for logging what was added during migration.
301
+ * @param {object} defaults
302
+ * @param {object} user
303
+ * @param {string} prefix
304
+ * @returns {string[]} Array of field paths that were added
305
+ */
306
+ export const findNewFields = (defaults, user, prefix = '') => {
307
+
308
+ const newFields = [];
309
+
310
+ if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
311
+ return newFields;
312
+ }
313
+
314
+ for (const key of Object.keys(defaults)) {
315
+ const fieldPath = prefix ? `${prefix}.${key}` : key;
316
+
317
+ if (!(key in user)) {
318
+ newFields.push(fieldPath);
319
+ } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
320
+ if (typeof user[key] === 'object' && user[key] !== null && !Array.isArray(user[key])) {
321
+ newFields.push(...findNewFields(defaults[key], user[key], fieldPath));
322
+ }
323
+ }
324
+ }
325
+
326
+ return newFields;
327
+ };
328
+
329
+ /**
330
+ * Get the directory where this plugin is installed.
331
+ * Used to find bundled assets like example.config.jsonc
332
+ */
333
+ const getPluginDir = () => {
334
+ const __filename = fileURLToPath(import.meta.url);
335
+ const __dirname = path.dirname(__filename);
336
+ return path.dirname(__dirname); // Go up from util/ to plugin root
337
+ };
338
+
339
+ /**
340
+ * Generate a comprehensive default configuration file content.
341
+ * This provides users with ALL available options fully documented.
342
+ * @param {object} overrides - Existing configuration to preserve
343
+ * @param {string} version - Current version to set in config
344
+ */
345
+ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
346
+ return `{
347
+ // ============================================================
348
+ // OpenCode Smart Voice Notify - Configuration
349
+ // ============================================================
350
+ //
351
+ // This file was auto-generated with all available options.
352
+ // Customize the settings below to your preference.
353
+ //
354
+ // Sound files have been automatically copied to:
355
+ // ~/.config/opencode/assets/
356
+ //
357
+ // Documentation: https://github.com/MasuRii/opencode-smart-voice-notify
358
+ //
359
+ // ============================================================
360
+
361
+ // Internal version tracking - DO NOT REMOVE
362
+ "_configVersion": "${version}",
363
+
364
+ // ============================================================
365
+ // PLUGIN ENABLE/DISABLE
366
+ // ============================================================
367
+ // Master switch to enable or disable the entire plugin.
368
+ // Set to false to disable all notifications without uninstalling.
369
+ "enabled": ${overrides.enabled !== undefined ? overrides.enabled : true},
370
+
371
+ // ============================================================
372
+ // GRANULAR NOTIFICATION CONTROL
373
+ // ============================================================
374
+ // Enable or disable notifications for specific event types.
375
+ // If disabled, no sound, TTS, desktop, or webhook notifications
376
+ // will be sent for that specific category.
377
+ "enableIdleNotification": ${overrides.enableIdleNotification !== undefined ? overrides.enableIdleNotification : true}, // Agent finished work
378
+ "enablePermissionNotification": ${overrides.enablePermissionNotification !== undefined ? overrides.enablePermissionNotification : true}, // Agent needs permission
379
+ "enableQuestionNotification": ${overrides.enableQuestionNotification !== undefined ? overrides.enableQuestionNotification : true}, // Agent asks a question
380
+ "enableErrorNotification": ${overrides.enableErrorNotification !== undefined ? overrides.enableErrorNotification : false}, // Agent encountered an error
381
+
382
+ // Enable or disable reminders for specific event types.
383
+ // If disabled, the initial notification will still fire, but no
384
+ // follow-up TTS reminders will be scheduled.
385
+ "enableIdleReminder": ${overrides.enableIdleReminder !== undefined ? overrides.enableIdleReminder : true},
386
+ "enablePermissionReminder": ${overrides.enablePermissionReminder !== undefined ? overrides.enablePermissionReminder : true},
387
+ "enableQuestionReminder": ${overrides.enableQuestionReminder !== undefined ? overrides.enableQuestionReminder : true},
388
+ "enableErrorReminder": ${overrides.enableErrorReminder !== undefined ? overrides.enableErrorReminder : false},
389
+
390
+ // ============================================================
391
+ // NOTIFICATION MODE SETTINGS (Smart Notification System)
392
+ // ============================================================
393
+ // Controls how notifications are delivered:
394
+ // 'sound-first' - Play sound immediately, TTS reminder after delay (RECOMMENDED)
395
+ // 'tts-first' - Speak TTS immediately, no sound
396
+ // 'both' - Play sound AND speak TTS immediately
397
+ // 'sound-only' - Only play sound, no TTS at all
398
+ "notificationMode": "${overrides.notificationMode || 'sound-first'}",
399
+
400
+ // ============================================================
401
+ // TTS REMINDER SETTINGS (When user doesn't respond to sound)
402
+ // ============================================================
403
+
404
+ // Enable TTS reminder if user doesn't respond after sound notification
405
+ "enableTTSReminder": ${overrides.enableTTSReminder !== undefined ? overrides.enableTTSReminder : true},
406
+
407
+ // Delay (in seconds) before TTS reminder fires
408
+ // Set globally or per-notification type
409
+ "ttsReminderDelaySeconds": ${overrides.ttsReminderDelaySeconds !== undefined ? overrides.ttsReminderDelaySeconds : 30}, // Global default
410
+ "idleReminderDelaySeconds": ${overrides.idleReminderDelaySeconds !== undefined ? overrides.idleReminderDelaySeconds : 30}, // For task completion notifications
411
+ "permissionReminderDelaySeconds": ${overrides.permissionReminderDelaySeconds !== undefined ? overrides.permissionReminderDelaySeconds : 20}, // For permission requests (more urgent)
412
+
413
+ // Follow-up reminders if user STILL doesn't respond after first TTS
414
+ "enableFollowUpReminders": ${overrides.enableFollowUpReminders !== undefined ? overrides.enableFollowUpReminders : true},
415
+ "maxFollowUpReminders": ${overrides.maxFollowUpReminders !== undefined ? overrides.maxFollowUpReminders : 3}, // Max number of follow-up TTS reminders
416
+ "reminderBackoffMultiplier": ${overrides.reminderBackoffMultiplier !== undefined ? overrides.reminderBackoffMultiplier : 1.5}, // Each follow-up waits longer (30s, 45s, 67s...)
417
+
418
+ // ============================================================
419
+ // TTS ENGINE SELECTION
420
+ // ============================================================
421
+ // 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI)
422
+ // 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
423
+ // 'edge' - Good quality neural voices (free, requires: pip install edge-tts)
424
+ // 'sapi' - Windows built-in voices (free, offline, robotic)
425
+ "ttsEngine": "${overrides.ttsEngine || 'elevenlabs'}",
426
+
427
+ // Enable TTS for notifications (falls back to sound files if TTS fails)
428
+ "enableTTS": ${overrides.enableTTS !== undefined ? overrides.enableTTS : true},
429
+
430
+ // ============================================================
431
+ // ELEVENLABS SETTINGS (Best Quality - Anime-like Voices)
432
+ // ============================================================
433
+ // Get your API key from: https://elevenlabs.io/app/settings/api-keys
434
+ // Free tier: 10,000 characters/month
435
+ //
436
+ // To use ElevenLabs:
437
+ // 1. Uncomment elevenLabsApiKey and add your key
438
+ // 2. Change ttsEngine above to "elevenlabs"
439
+ //
440
+ ${overrides.elevenLabsApiKey ? `"elevenLabsApiKey": "${overrides.elevenLabsApiKey}",` : `// "elevenLabsApiKey": "YOUR_API_KEY_HERE",`}
441
+
442
+ // Voice ID - Recommended cute/anime-like voices:
443
+ // 'cgSgspJ2msm6clMCkdW9' - Jessica (Playful, Bright, Warm) - RECOMMENDED
444
+ // 'FGY2WhTYpPnrIDTdsKH5' - Laura (Enthusiast, Quirky)
445
+ // 'jsCqWAovK2LkecY7zXl4' - Freya (Expressive, Confident)
446
+ // 'EXAVITQu4vr4xnSDxMaL' - Sarah (Soft, Warm)
447
+ // Browse more at: https://elevenlabs.io/voice-library
448
+ "elevenLabsVoiceId": "${overrides.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9'}",
449
+
450
+ // Model: 'eleven_turbo_v2_5' (fast, good), 'eleven_multilingual_v2' (highest quality)
451
+ "elevenLabsModel": "${overrides.elevenLabsModel || 'eleven_turbo_v2_5'}",
452
+
453
+ // Voice tuning (0.0 to 1.0)
454
+ "elevenLabsStability": ${overrides.elevenLabsStability !== undefined ? overrides.elevenLabsStability : 0.5}, // Lower = more expressive, Higher = more consistent
455
+ "elevenLabsSimilarity": ${overrides.elevenLabsSimilarity !== undefined ? overrides.elevenLabsSimilarity : 0.75}, // How closely to match the original voice
456
+ "elevenLabsStyle": ${overrides.elevenLabsStyle !== undefined ? overrides.elevenLabsStyle : 0.5}, // Style exaggeration (higher = more expressive)
457
+
458
+ // ============================================================
459
+ // EDGE TTS SETTINGS (Free Neural Voices - Default Engine)
460
+ // ============================================================
461
+ // Requires: pip install edge-tts
462
+
463
+ // Voice options (run 'edge-tts --list-voices' to see all):
464
+ // 'en-US-AnaNeural' - Young, cute, cartoon-like (RECOMMENDED)
465
+ // 'en-US-JennyNeural' - Friendly, warm
466
+ // 'en-US-AriaNeural' - Confident, clear
467
+ // 'en-GB-SoniaNeural' - British, friendly
468
+ // 'en-AU-NatashaNeural' - Australian, warm
469
+ "edgeVoice": "${overrides.edgeVoice || 'en-US-JennyNeural'}",
470
+
471
+ // Pitch adjustment: +0Hz to +100Hz (higher = more anime-like)
472
+ "edgePitch": "${overrides.edgePitch || '+0Hz'}",
473
+
474
+ // Speech rate: -50% to +100%
475
+ "edgeRate": "${overrides.edgeRate || '+10%'}",
476
+
477
+ // ============================================================
478
+ // SAPI SETTINGS (Windows Built-in - Last Resort Fallback)
479
+ // ============================================================
480
+
481
+ // Voice (run PowerShell to list all installed voices):
482
+ // Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | % { $_.VoiceInfo.Name }
483
+ //
484
+ // Common Windows voices:
485
+ // 'Microsoft Zira Desktop' - Female, US English
486
+ // 'Microsoft David Desktop' - Male, US English
487
+ // 'Microsoft Hazel Desktop' - Female, UK English
488
+ "sapiVoice": "${overrides.sapiVoice || 'Microsoft Zira Desktop'}",
489
+
490
+ // Speech rate: -10 (slowest) to +10 (fastest), 0 is normal
491
+ "sapiRate": ${overrides.sapiRate !== undefined ? overrides.sapiRate : -1},
492
+
493
+ // Pitch: 'x-low', 'low', 'medium', 'high', 'x-high'
494
+ "sapiPitch": "${overrides.sapiPitch || 'medium'}",
495
+
496
+ // Volume: 'silent', 'x-soft', 'soft', 'medium', 'loud', 'x-loud'
497
+ "sapiVolume": "${overrides.sapiVolume || 'loud'}",
498
+
499
+ // ============================================================
500
+ // OPENAI-COMPATIBLE TTS SETTINGS (Kokoro, LocalAI, OpenAI, etc.)
501
+ // ============================================================
502
+ // Any OpenAI-compatible /v1/audio/speech endpoint.
503
+ // Examples: Kokoro, OpenAI, LocalAI, Coqui, AllTalk, etc.
504
+ //
505
+ // To use OpenAI-compatible TTS:
506
+ // 1. Set ttsEngine above to "openai"
507
+ // 2. Set openaiTtsEndpoint to your server URL (without /v1/audio/speech)
508
+ // 3. Configure voice and model for your server
509
+
510
+ // Base URL for your TTS server (e.g., "http://192.168.86.43:8880")
511
+ "openaiTtsEndpoint": "${overrides.openaiTtsEndpoint || ''}",
512
+
513
+ // API key (leave empty if your server doesn't require auth)
514
+ "openaiTtsApiKey": "${overrides.openaiTtsApiKey || ''}",
515
+
516
+ // Model name (server-dependent, e.g., "tts-1", "kokoro", "xtts")
517
+ "openaiTtsModel": "${overrides.openaiTtsModel || 'tts-1'}",
518
+
519
+ // Voice name (server-dependent)
520
+ // Kokoro voices: "af_heart", "af_bella", "am_adam", etc.
521
+ // OpenAI voices: "alloy", "echo", "fable", "onyx", "nova", "shimmer"
522
+ "openaiTtsVoice": "${overrides.openaiTtsVoice || 'alloy'}",
523
+
524
+ // Audio format: "mp3", "opus", "aac", "flac", "wav", "pcm"
525
+ "openaiTtsFormat": "${overrides.openaiTtsFormat || 'mp3'}",
526
+
527
+ // Speech speed: 0.25 to 4.0 (1.0 = normal)
528
+ "openaiTtsSpeed": ${overrides.openaiTtsSpeed !== undefined ? overrides.openaiTtsSpeed : 1.0},
529
+
530
+ // ============================================================
531
+ // INITIAL TTS MESSAGES (Used immediately or after sound)
532
+ // These are randomly selected each time for variety
533
+ // ============================================================
534
+
535
+ // Messages when agent finishes work (task completion)
536
+ "idleTTSMessages": ${formatJSON(overrides.idleTTSMessages || [
537
+ "All done! Your task has been completed successfully.",
538
+ "Hey there! I finished working on your request.",
539
+ "Task complete! Ready for your review whenever you are.",
540
+ "Good news! Everything is done and ready for you.",
541
+ "Finished! Let me know if you need anything else."
542
+ ], 4)},
543
+
544
+ // Messages for permission requests
545
+ "permissionTTSMessages": ${formatJSON(overrides.permissionTTSMessages || [
546
+ "Attention please! I need your permission to continue.",
547
+ "Hey! Quick approval needed to proceed with the task.",
548
+ "Heads up! There is a permission request waiting for you.",
549
+ "Excuse me! I need your authorization before I can continue.",
550
+ "Permission required! Please review and approve when ready."
551
+ ], 4)},
552
+
553
+ // Messages for MULTIPLE permission requests (use {count} placeholder)
554
+ // Used when several permissions arrive simultaneously
555
+ "permissionTTSMessagesMultiple": ${formatJSON(overrides.permissionTTSMessagesMultiple || [
556
+ "Attention please! There are {count} permission requests waiting for your approval.",
557
+ "Hey! {count} permissions need your approval to continue.",
558
+ "Heads up! You have {count} pending permission requests.",
559
+ "Excuse me! I need your authorization for {count} different actions.",
560
+ "{count} permissions required! Please review and approve when ready."
561
+ ], 4)},
562
+
563
+ // ============================================================
564
+ // TTS REMINDER MESSAGES (More urgent - used after delay if no response)
565
+ // These are more personalized and urgent to get user attention
566
+ // ============================================================
567
+
568
+ // Reminder messages when agent finished but user hasn't responded
569
+ "idleReminderTTSMessages": ${formatJSON(overrides.idleReminderTTSMessages || [
570
+ "Hey, are you still there? Your task has been waiting for review.",
571
+ "Just a gentle reminder - I finished your request a while ago!",
572
+ "Hello? I completed your task. Please take a look when you can.",
573
+ "Still waiting for you! The work is done and ready for review.",
574
+ "Knock knock! Your completed task is patiently waiting for you."
575
+ ], 4)},
576
+
577
+ // Reminder messages when permission still needed
578
+ "permissionReminderTTSMessages": ${formatJSON(overrides.permissionReminderTTSMessages || [
579
+ "Hey! I still need your permission to continue. Please respond!",
580
+ "Reminder: There is a pending permission request. I cannot proceed without you.",
581
+ "Hello? I am waiting for your approval. This is getting urgent!",
582
+ "Please check your screen! I really need your permission to move forward.",
583
+ "Still waiting for authorization! The task is on hold until you respond."
584
+ ], 4)},
585
+
586
+ // Reminder messages for MULTIPLE permissions (use {count} placeholder)
587
+ "permissionReminderTTSMessagesMultiple": ${formatJSON(overrides.permissionReminderTTSMessagesMultiple || [
588
+ "Hey! I still need your approval for {count} permissions. Please respond!",
589
+ "Reminder: There are {count} pending permission requests. I cannot proceed without you.",
590
+ "Hello? I am waiting for your approval on {count} items. This is getting urgent!",
591
+ "Please check your screen! {count} permissions are waiting for your response.",
592
+ "Still waiting for authorization on {count} requests! The task is on hold."
593
+ ], 4)},
594
+
595
+ // ============================================================
596
+ // PERMISSION BATCHING (Multiple permissions at once)
597
+ // ============================================================
598
+ // When multiple permissions arrive simultaneously, batch them into one notification
599
+ // This prevents overlapping sounds when 5+ permissions come at once
600
+
601
+ // Batch window (ms) - how long to wait for more permissions before notifying
602
+ "permissionBatchWindowMs": ${overrides.permissionBatchWindowMs !== undefined ? overrides.permissionBatchWindowMs : 800},
603
+
604
+ // ============================================================
605
+ // QUESTION TOOL SETTINGS (SDK v1.1.7+ - Agent asking user questions)
606
+ // ============================================================
607
+ // The "question" tool allows the LLM to ask users questions during execution.
608
+ // This is useful for gathering preferences, clarifying instructions, or getting
609
+ // decisions on implementation choices.
610
+
611
+ // Messages when agent asks user a question
612
+ "questionTTSMessages": ${formatJSON(overrides.questionTTSMessages || [
613
+ "Hey! I have a question for you. Please check your screen.",
614
+ "Attention! I need your input to continue.",
615
+ "Quick question! Please take a look when you have a moment.",
616
+ "I need some clarification. Could you please respond?",
617
+ "Question time! Your input is needed to proceed."
618
+ ], 4)},
619
+
620
+ // Messages for MULTIPLE questions (use {count} placeholder)
621
+ "questionTTSMessagesMultiple": ${formatJSON(overrides.questionTTSMessagesMultiple || [
622
+ "Hey! I have {count} questions for you. Please check your screen.",
623
+ "Attention! I need your input on {count} items to continue.",
624
+ "{count} questions need your attention. Please take a look!",
625
+ "I need some clarifications. There are {count} questions waiting for you.",
626
+ "Question time! {count} questions need your response to proceed."
627
+ ], 4)},
628
+
629
+ // Reminder messages for questions (more urgent - used after delay)
630
+ "questionReminderTTSMessages": ${formatJSON(overrides.questionReminderTTSMessages || [
631
+ "Hey! I am still waiting for your answer. Please check the questions!",
632
+ "Reminder: There is a question waiting for your response.",
633
+ "Hello? I need your input to continue. Please respond when you can.",
634
+ "Still waiting for your answer! The task is on hold.",
635
+ "Your input is needed! Please check the pending question."
636
+ ], 4)},
637
+
638
+ // Reminder messages for MULTIPLE questions (use {count} placeholder)
639
+ "questionReminderTTSMessagesMultiple": ${formatJSON(overrides.questionReminderTTSMessagesMultiple || [
640
+ "Hey! I am still waiting for answers to {count} questions. Please respond!",
641
+ "Reminder: There are {count} questions waiting for your response.",
642
+ "Hello? I need your input on {count} items. Please respond when you can.",
643
+ "Still waiting for your answers on {count} questions! The task is on hold.",
644
+ "Your input is needed! {count} questions are pending your response."
645
+ ], 4)},
646
+
647
+ // Delay (in seconds) before question reminder fires
648
+ "questionReminderDelaySeconds": ${overrides.questionReminderDelaySeconds !== undefined ? overrides.questionReminderDelaySeconds : 25},
649
+
650
+ // Question batch window (ms) - how long to wait for more questions before notifying
651
+ "questionBatchWindowMs": ${overrides.questionBatchWindowMs !== undefined ? overrides.questionBatchWindowMs : 800},
652
+
653
+ // ============================================================
654
+ // ERROR NOTIFICATION SETTINGS (Session Errors)
655
+ // ============================================================
656
+ // Notify users when the agent encounters an error during execution.
657
+ // Error notifications use more urgent messaging to get user attention.
658
+
659
+ // Messages when agent encounters an error
660
+ "errorTTSMessages": ${formatJSON(overrides.errorTTSMessages || [
661
+ "Oops! Something went wrong. Please check for errors.",
662
+ "Alert! The agent encountered an error and needs your attention.",
663
+ "Error detected! Please review the issue when you can.",
664
+ "Houston, we have a problem! An error occurred during the task.",
665
+ "Heads up! There was an error that requires your attention."
666
+ ], 4)},
667
+
668
+ // Messages for MULTIPLE errors (use {count} placeholder)
669
+ "errorTTSMessagesMultiple": ${formatJSON(overrides.errorTTSMessagesMultiple || [
670
+ "Oops! There are {count} errors that need your attention.",
671
+ "Alert! The agent encountered {count} errors. Please review.",
672
+ "{count} errors detected! Please check when you can.",
673
+ "Houston, we have {count} problems! Multiple errors occurred.",
674
+ "Heads up! {count} errors require your attention."
675
+ ], 4)},
676
+
677
+ // Reminder messages for errors (more urgent - used after delay)
678
+ "errorReminderTTSMessages": ${formatJSON(overrides.errorReminderTTSMessages || [
679
+ "Hey! There's still an error waiting for your attention.",
680
+ "Reminder: An error occurred and hasn't been addressed yet.",
681
+ "The agent is stuck! Please check the error when you can.",
682
+ "Still waiting! That error needs your attention.",
683
+ "Don't forget! There's an unresolved error in your session."
684
+ ], 4)},
685
+
686
+ // Reminder messages for MULTIPLE errors (use {count} placeholder)
687
+ "errorReminderTTSMessagesMultiple": ${formatJSON(overrides.errorReminderTTSMessagesMultiple || [
688
+ "Hey! There are still {count} errors waiting for your attention.",
689
+ "Reminder: {count} errors occurred and haven't been addressed yet.",
690
+ "The agent is stuck! Please check the {count} errors when you can.",
691
+ "Still waiting! {count} errors need your attention.",
692
+ "Don't forget! There are {count} unresolved errors in your session."
693
+ ], 4)},
694
+
695
+ // Delay (in seconds) before error reminder fires (shorter than idle for urgency)
696
+ "errorReminderDelaySeconds": ${overrides.errorReminderDelaySeconds !== undefined ? overrides.errorReminderDelaySeconds : 20},
697
+
698
+ // ============================================================
699
+ // AI MESSAGE GENERATION (OpenAI-Compatible Endpoints)
700
+ // ============================================================
701
+ // Use a local/self-hosted AI to generate dynamic notification messages
702
+ // instead of using preset static messages. The AI generates the text,
703
+ // which is then spoken by your configured TTS engine (ElevenLabs, Edge, etc.)
704
+ //
705
+ // Supports: Ollama, LM Studio, LocalAI, vLLM, llama.cpp, Jan.ai, and any
706
+ // OpenAI-compatible endpoint. You provide your own endpoint URL and API key.
707
+
708
+ // Enable AI-generated messages (experimental feature)
709
+ "enableAIMessages": ${overrides.enableAIMessages !== undefined ? overrides.enableAIMessages : false},
710
+
711
+ // Your AI server endpoint URL (e.g., Ollama: http://localhost:11434/v1)
712
+ // Common endpoints:
713
+ // Ollama: http://localhost:11434/v1
714
+ // LM Studio: http://localhost:1234/v1
715
+ // LocalAI: http://localhost:8080/v1
716
+ // vLLM: http://localhost:8000/v1
717
+ // Jan.ai: http://localhost:1337/v1
718
+ "aiEndpoint": "${overrides.aiEndpoint || 'http://localhost:11434/v1'}",
719
+
720
+ // Model name to use (depends on what's loaded in your AI server)
721
+ // Examples: "llama3", "mistral", "phi3", "gemma2", "qwen2"
722
+ "aiModel": "${overrides.aiModel || 'llama3'}",
723
+
724
+ // API key for your AI server (leave empty for Ollama/LM Studio/LocalAI)
725
+ // Only needed if your server requires authentication
726
+ "aiApiKey": "${overrides.aiApiKey || ''}",
727
+
728
+ // Request timeout in milliseconds (local AI can be slow on first request)
729
+ "aiTimeout": ${overrides.aiTimeout !== undefined ? overrides.aiTimeout : 15000},
730
+
731
+ // Fallback to static preset messages if AI generation fails
732
+ "aiFallbackToStatic": ${overrides.aiFallbackToStatic !== undefined ? overrides.aiFallbackToStatic : true},
733
+
734
+ // Enable context-aware AI messages (includes project name, task title, and change summary)
735
+ // When enabled, AI-generated notifications will include relevant context like:
736
+ // - Project name (e.g., "Your work on MyProject is complete!")
737
+ // - Task/session title if available
738
+ // - Change summary (files modified, lines added/deleted)
739
+ // Disabled by default - enable this for more personalized notifications
740
+ "enableContextAwareAI": ${overrides.enableContextAwareAI !== undefined ? overrides.enableContextAwareAI : false},
741
+
742
+ // Custom prompts for each notification type
743
+ // The AI will generate a short message based on these prompts
744
+ // Keep prompts concise - they're sent with each notification
745
+ "aiPrompts": ${formatJSON(overrides.aiPrompts || {
746
+ "idle": "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.",
747
+ "permission": "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.",
748
+ "question": "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.",
749
+ "error": "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.",
750
+ "idleReminder": "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.",
751
+ "permissionReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.",
752
+ "questionReminder": "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.",
753
+ "errorReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes."
754
+ }, 4)},
755
+
756
+ // ============================================================
757
+ // SOUND FILES (For immediate notifications)
758
+ // These are played first before TTS reminder kicks in
759
+ // ============================================================
760
+ // Paths are relative to ~/.config/opencode/ directory
761
+ // Sound files are automatically copied here on first run
762
+ // You can replace with your own custom MP3/WAV files
763
+
764
+ "idleSound": "${overrides.idleSound || 'assets/Soft-high-tech-notification-sound-effect.mp3'}",
765
+ "permissionSound": "${overrides.permissionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
766
+ "questionSound": "${overrides.questionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
767
+ "errorSound": "${overrides.errorSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
768
+
769
+ // ============================================================
770
+ // GENERAL SETTINGS
771
+ // ============================================================
772
+
773
+ // Wake monitor from sleep when notifying (Windows/macOS)
774
+ "wakeMonitor": ${overrides.wakeMonitor !== undefined ? overrides.wakeMonitor : true},
775
+
776
+ // Force system volume up if below threshold
777
+ "forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : false},
778
+
779
+ // Volume threshold (0-100): force volume if current level is below this
780
+ "volumeThreshold": ${overrides.volumeThreshold !== undefined ? overrides.volumeThreshold : 50},
781
+
782
+ // Show TUI toast notifications in OpenCode terminal
783
+ "enableToast": ${overrides.enableToast !== undefined ? overrides.enableToast : true},
784
+
785
+ // Enable audio notifications (sound files and TTS)
786
+ "enableSound": ${overrides.enableSound !== undefined ? overrides.enableSound : true},
787
+
788
+ // ============================================================
789
+ // DESKTOP NOTIFICATION SETTINGS
790
+ // ============================================================
791
+ // Native desktop notifications (Windows Toast, macOS Notification Center, Linux notify-send)
792
+ // These appear as system notifications alongside sound and TTS.
793
+ //
794
+ // Note: On Linux, you may need to install libnotify-bin:
795
+ // Ubuntu/Debian: sudo apt install libnotify-bin
796
+ // Fedora: sudo dnf install libnotify
797
+ // Arch: sudo pacman -S libnotify
798
+
799
+ // Enable native desktop notifications
800
+ "enableDesktopNotification": ${overrides.enableDesktopNotification !== undefined ? overrides.enableDesktopNotification : true},
801
+
802
+ // How long the notification stays on screen (in seconds)
803
+ // Note: Some platforms may ignore this (especially Windows 10+)
804
+ "desktopNotificationTimeout": ${overrides.desktopNotificationTimeout !== undefined ? overrides.desktopNotificationTimeout : 5},
805
+
806
+ // Include the project name in notification titles for easier identification
807
+ // Example: "OpenCode - MyProject" instead of just "OpenCode"
808
+ "showProjectInNotification": ${overrides.showProjectInNotification !== undefined ? overrides.showProjectInNotification : true},
809
+
810
+ // ============================================================
811
+ // FOCUS DETECTION SETTINGS
812
+ // ============================================================
813
+ // Suppress notifications when you're actively looking at the terminal.
814
+ // This prevents notifications from interrupting you when you're already
815
+ // paying attention to the OpenCode terminal.
816
+ //
817
+ // PLATFORM SUPPORT:
818
+ // macOS: Full support - Uses AppleScript to detect frontmost application
819
+ // Windows: Not supported - No reliable API available
820
+ // Linux: Not supported - Varies by desktop environment
821
+ //
822
+ // When focus detection is not supported on your platform, notifications
823
+ // will always be sent (fail-open behavior).
824
+
825
+ // Suppress sound and desktop notifications when terminal is focused
826
+ // TTS reminders are still allowed (user might step away after task completes)
827
+ "suppressWhenFocused": ${overrides.suppressWhenFocused !== undefined ? overrides.suppressWhenFocused : true},
828
+
829
+ // Override focus detection: always send notifications even when terminal is focused
830
+ // Set to true to disable focus-based suppression entirely
831
+ "alwaysNotify": ${overrides.alwaysNotify !== undefined ? overrides.alwaysNotify : false},
832
+
833
+ // ============================================================
834
+ // WEBHOOK NOTIFICATION SETTINGS (Discord/Generic)
835
+ // ============================================================
836
+ // Send notifications to a Discord webhook or any compatible endpoint.
837
+ // This allows you to receive notifications on your phone or other devices.
838
+
839
+ // Enable webhook notifications
840
+ "enableWebhook": ${overrides.enableWebhook !== undefined ? overrides.enableWebhook : false},
841
+
842
+ // Webhook URL (e.g., https://discord.com/api/webhooks/...)
843
+ "webhookUrl": "${overrides.webhookUrl || ''}",
844
+
845
+ // Username to show in the webhook message
846
+ "webhookUsername": "${overrides.webhookUsername || 'OpenCode Notify'}",
847
+
848
+ // Events that should trigger a webhook notification
849
+ // Options: "idle", "permission", "error", "question"
850
+ "webhookEvents": ${formatJSON(overrides.webhookEvents || ["idle", "permission", "error", "question"], 4)},
851
+
852
+ // Mention @everyone on permission requests (Discord only)
853
+ "webhookMentionOnPermission": ${overrides.webhookMentionOnPermission !== undefined ? overrides.webhookMentionOnPermission : false},
854
+
855
+ // ============================================================
856
+ // SOUND THEME SETTINGS (Themed Sound Packs)
857
+ // ============================================================
858
+ // Configure a directory containing custom sound files for notifications.
859
+ // This allows you to use themed sound packs (e.g., Warcraft, StarCraft, etc.)
860
+ //
861
+ // Directory structure should contain:
862
+ // /path/to/theme/idle/ - Sounds for task completion
863
+ // /path/to/theme/permission/ - Sounds for permission requests
864
+ // /path/to/theme/error/ - Sounds for agent errors
865
+ // /path/to/theme/question/ - Sounds for agent questions
866
+ //
867
+ // If a specific event folder is missing, it falls back to default sounds.
868
+
869
+ // Path to your custom sound theme directory (absolute path recommended)
870
+ "soundThemeDir": "${overrides.soundThemeDir || ''}",
871
+
872
+ // Pick a random sound from the appropriate theme folder for each notification
873
+ "randomizeSoundFromTheme": ${overrides.randomizeSoundFromTheme !== undefined ? overrides.randomizeSoundFromTheme : true},
874
+
875
+ // ============================================================
876
+ // PER-PROJECT SOUND SETTINGS
877
+ // ============================================================
878
+ // Assign a unique notification sound to each project based on its path.
879
+ // This helps you distinguish which project is notifying you when working
880
+ // on multiple tasks simultaneously.
881
+ //
882
+ // Note: Requires sounds named 'ding1.mp3' through 'ding6.mp3' in your
883
+ // assets/ folder. If disabled, default sound files are used.
884
+
885
+ // Enable unique sounds per project
886
+ "perProjectSounds": ${overrides.perProjectSounds !== undefined ? overrides.perProjectSounds : false},
887
+
888
+ // Seed value to change sound assignments (0-999)
889
+ "projectSoundSeed": ${overrides.projectSoundSeed !== undefined ? overrides.projectSoundSeed : 0},
890
+
891
+ // Consider monitor asleep after this many seconds of inactivity (Windows only)
892
+ "idleThresholdSeconds": ${overrides.idleThresholdSeconds !== undefined ? overrides.idleThresholdSeconds : 60},
893
+
894
+ // Enable debug logging to ~/.config/opencode/logs/smart-voice-notify-debug.log
895
+ // The logs folder is created automatically when debug logging is enabled
896
+ // Useful for troubleshooting notification issues
897
+ "debugLog": ${overrides.debugLog !== undefined ? overrides.debugLog : false}
898
+ }`;
899
+ };
900
+
901
+ /**
902
+ * Copy bundled assets (sound files) to the OpenCode config directory.
903
+ * @param {string} configDir - The OpenCode config directory path
904
+ */
905
+ const copyBundledAssets = (configDir) => {
906
+ try {
907
+ const pluginDir = getPluginDir();
908
+ const sourceAssetsDir = path.join(pluginDir, 'assets');
909
+ const targetAssetsDir = path.join(configDir, 'assets');
910
+
911
+ // Check if source assets exist (they should be bundled with the plugin)
912
+ if (!fs.existsSync(sourceAssetsDir)) {
913
+ return; // No bundled assets to copy
914
+ }
915
+
916
+ // Create target assets directory if it doesn't exist
917
+ if (!fs.existsSync(targetAssetsDir)) {
918
+ fs.mkdirSync(targetAssetsDir, { recursive: true });
919
+ }
920
+
921
+ // Copy each asset file if it doesn't already exist in target
922
+ const assetFiles = fs.readdirSync(sourceAssetsDir);
923
+ for (const file of assetFiles) {
924
+ const sourcePath = path.join(sourceAssetsDir, file);
925
+ const targetPath = path.join(targetAssetsDir, file);
926
+
927
+ // Only copy if target doesn't exist (don't overwrite user customizations)
928
+ if (!fs.existsSync(targetPath) && fs.statSync(sourcePath).isFile()) {
929
+ fs.copyFileSync(sourcePath, targetPath);
930
+ }
931
+ }
932
+ } catch (error) {
933
+ // Silently fail - assets are optional
934
+ }
935
+ };
936
+
937
+ /**
938
+ * Loads a configuration file from the OpenCode config directory.
939
+ * If the file doesn't exist, creates a default config file with full documentation.
940
+ * If the file exists, performs smart merging to add new fields without overwriting user values.
941
+ *
942
+ * IMPORTANT: User values are NEVER overwritten. Only new fields from plugin updates are added.
943
+ *
944
+ * @param {string} name - Name of the config file (without .jsonc extension)
945
+ * @param {object} defaults - Default values if file doesn't exist or is invalid
946
+ * @returns {object}
947
+ */
948
+ export const loadConfig = (name, defaults = {}) => {
949
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
950
+ const filePath = path.join(configDir, `${name}.jsonc`);
951
+
952
+ // Get current version from package.json
953
+ const pluginDir = getPluginDir();
954
+ const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8'));
955
+ const currentVersion = pkg.version;
956
+
957
+ // Get default config object with current version early so it can be used for peeking
958
+ const defaultConfig = getDefaultConfigObject();
959
+ defaultConfig._configVersion = currentVersion;
960
+
961
+ // Always ensure bundled assets are present
962
+ copyBundledAssets(configDir);
963
+
964
+ // Try to load existing config
965
+ let existingConfig = null;
966
+ if (fs.existsSync(filePath)) {
967
+ try {
968
+ const content = fs.readFileSync(filePath, 'utf-8');
969
+ existingConfig = parseJSONC(content);
970
+ } catch (error) {
971
+ // If file is invalid JSONC, we'll use defaults for this run but NOT overwrite the user's file
972
+ // This prevents accidental loss of configuration due to a simple syntax error
973
+ debugLogToFile(`Warning: Config file at ${filePath} is invalid (${error.message}). Using default values for now. Please check your config for syntax errors.`, configDir);
974
+ existingConfig = null; // Forces CASE 1 logic but we'll modify it to avoid writing
975
+
976
+ // SMART PEEK: Even if parsing fails, try to see if "enabled" field is set to false/disabled
977
+ // to respect the user's intent to disable the plugin even with syntax errors.
978
+ try {
979
+ const rawContent = fs.readFileSync(filePath, 'utf-8');
980
+ // Match both boolean and string values for "enabled"
981
+ const enabledMatch = rawContent.match(/"enabled"\s*:\s*(false|true|"disabled"|"enabled"|'disabled'|'enabled')/i);
982
+ if (enabledMatch) {
983
+ const val = enabledMatch[1].replace(/["']/g, '').toLowerCase();
984
+ const isActuallyEnabled = (val === 'true' || val === 'enabled');
985
+
986
+ // Inject into defaults and defaultConfig so it's picked up
987
+ defaults.enabled = isActuallyEnabled;
988
+ defaultConfig.enabled = isActuallyEnabled;
989
+ debugLogToFile(`Detected 'enabled: ${isActuallyEnabled}' via emergency regex peek (syntax error in file)`, configDir);
990
+ }
991
+ } catch (e) {
992
+ // Peek failed, just proceed with CASE 1
993
+ }
994
+ }
995
+
996
+ }
997
+
998
+ // CASE 1: No existing config (missing or invalid)
999
+ if (!existingConfig) {
1000
+
1001
+ try {
1002
+ // Ensure config directory exists
1003
+ if (!fs.existsSync(configDir)) {
1004
+ fs.mkdirSync(configDir, { recursive: true });
1005
+ }
1006
+
1007
+ // ONLY write a fresh config file if it doesn't exist at all.
1008
+ // If it exists but was invalid, we already logged a warning and we'll just return defaults.
1009
+ if (!fs.existsSync(filePath)) {
1010
+ // Generate new config file with all documentation comments
1011
+ const newConfigContent = generateDefaultConfig({}, currentVersion);
1012
+ fs.writeFileSync(filePath, newConfigContent, 'utf-8');
1013
+ debugLogToFile(`Initialized default config at ${filePath}`, configDir);
1014
+ }
1015
+
1016
+ // Return the default config merged with any passed defaults
1017
+ return { ...defaults, ...defaultConfig };
1018
+ } catch (error) {
1019
+ // If creation fails, return defaults
1020
+ return { ...defaults, ...defaultConfig };
1021
+ }
1022
+ }
1023
+
1024
+
1025
+ // CASE 2: Existing config - smart merge to add new fields only
1026
+ // Find what new fields need to be added (for logging)
1027
+ const newFields = findNewFields(defaultConfig, existingConfig);
1028
+
1029
+ // Deep merge: user values preserved, only new fields added from defaults
1030
+ const mergedConfig = deepMerge(defaultConfig, existingConfig);
1031
+
1032
+ // Update version in merged config
1033
+ mergedConfig._configVersion = currentVersion;
1034
+
1035
+ // Only write back if there are new fields to add OR version changed
1036
+ const versionChanged = existingConfig._configVersion !== currentVersion;
1037
+
1038
+ if (newFields.length > 0 || versionChanged) {
1039
+ try {
1040
+ // Regenerate the config file with full documentation comments
1041
+ // Pass the merged config so user values are preserved in the output
1042
+ const newConfigContent = generateDefaultConfig(mergedConfig, currentVersion);
1043
+ fs.writeFileSync(filePath, newConfigContent, 'utf-8');
1044
+
1045
+ if (newFields.length > 0) {
1046
+ debugLogToFile(`Added ${newFields.length} new config field(s): ${newFields.join(', ')}`, configDir);
1047
+ }
1048
+ if (versionChanged) {
1049
+ debugLogToFile(`Config version updated to ${currentVersion}`, configDir);
1050
+ }
1051
+ } catch (error) {
1052
+ // If write fails, still return the merged config (just won't persist new fields)
1053
+ debugLogToFile(`Warning: Could not update config file: ${error.message}`, configDir);
1054
+ }
1055
+ }
1056
+
1057
+ return { ...defaults, ...mergedConfig };
1058
+ };