opencode-smart-voice-notify 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "opencode-smart-voice-notify",
3
- "version": "1.2.5",
3
+ "version": "1.3.0",
4
4
  "description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI), AI-generated dynamic messages, and intelligent reminder system",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
+ "scripts": {
8
+ "test": "bun test",
9
+ "test:watch": "bun test --watch",
10
+ "test:coverage": "bun test --coverage"
11
+ },
7
12
  "author": "MasuRii",
8
13
  "license": "MIT",
9
14
  "keywords": [
@@ -43,8 +48,10 @@
43
48
  "bun": ">=1.0.0"
44
49
  },
45
50
  "dependencies": {
46
- "@elevenlabs/elevenlabs-js": "^2.30.0",
47
- "msedge-tts": "^2.0.3"
51
+ "@elevenlabs/elevenlabs-js": "^2.32.0",
52
+ "detect-terminal": "^2.0.0",
53
+ "msedge-tts": "^2.0.3",
54
+ "node-notifier": "^10.0.1"
48
55
  },
49
56
  "peerDependencies": {
50
57
  "@opencode-ai/plugin": "^1.1.8"
@@ -7,8 +7,33 @@
7
7
  * Uses native fetch() - no external dependencies required.
8
8
  */
9
9
 
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import os from 'os';
10
13
  import { getTTSConfig } from './tts.js';
11
14
 
15
+ /**
16
+ * Debug logging to file (no console output).
17
+ * Logs are written to ~/.config/opencode/logs/smart-voice-notify-debug.log
18
+ * @param {string} message - Message to log
19
+ * @param {object} config - Config object with debugLog flag
20
+ */
21
+ const debugLog = (message, config) => {
22
+ if (!config?.debugLog) return;
23
+ try {
24
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
25
+ const logsDir = path.join(configDir, 'logs');
26
+ if (!fs.existsSync(logsDir)) {
27
+ fs.mkdirSync(logsDir, { recursive: true });
28
+ }
29
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
30
+ const timestamp = new Date().toISOString();
31
+ fs.appendFileSync(logFile, `[${timestamp}] [ai-messages] ${message}\n`);
32
+ } catch (e) {
33
+ // Silently fail - logging should never break the plugin
34
+ }
35
+ };
36
+
12
37
  /**
13
38
  * Generate a message using an OpenAI-compatible AI endpoint
14
39
  * @param {string} promptType - The type of prompt ('idle', 'permission', 'question', 'idleReminder', 'permissionReminder', 'questionReminder')
@@ -23,9 +48,12 @@ export async function generateAIMessage(promptType, context = {}) {
23
48
  return null;
24
49
  }
25
50
 
51
+ debugLog(`generateAIMessage: starting for promptType="${promptType}"`, config);
52
+
26
53
  // Get the prompt for this type
27
54
  let prompt = config.aiPrompts?.[promptType];
28
55
  if (!prompt) {
56
+ debugLog(`generateAIMessage: no prompt found for type "${promptType}"`, config);
29
57
  return null;
30
58
  }
31
59
 
@@ -39,6 +67,44 @@ export async function generateAIMessage(promptType, context = {}) {
39
67
  itemType = 'permission requests';
40
68
  }
41
69
  prompt = `${prompt} Important: There are ${context.count} ${itemType} (not just one) waiting for the user's attention. Mention the count in your message.`;
70
+ debugLog(`generateAIMessage: injected count context (count=${context.count}, type=${context.type})`, config);
71
+ }
72
+
73
+ // Inject session/project context if context-aware AI is enabled
74
+ if (config.enableContextAwareAI) {
75
+ debugLog(`generateAIMessage: context-aware AI is ENABLED`, config);
76
+ const contextParts = [];
77
+
78
+ if (context.projectName) {
79
+ contextParts.push(`Project: "${context.projectName}"`);
80
+ debugLog(`generateAIMessage: context includes projectName="${context.projectName}"`, config);
81
+ }
82
+
83
+ if (context.sessionTitle) {
84
+ contextParts.push(`Task: "${context.sessionTitle}"`);
85
+ debugLog(`generateAIMessage: context includes sessionTitle="${context.sessionTitle}"`, config);
86
+ }
87
+
88
+ if (context.sessionSummary) {
89
+ const { files, additions, deletions } = context.sessionSummary;
90
+ if (files !== undefined || additions !== undefined || deletions !== undefined) {
91
+ const summaryParts = [];
92
+ if (files !== undefined) summaryParts.push(`${files} file(s) modified`);
93
+ if (additions !== undefined) summaryParts.push(`+${additions} lines`);
94
+ if (deletions !== undefined) summaryParts.push(`-${deletions} lines`);
95
+ contextParts.push(`Changes: ${summaryParts.join(', ')}`);
96
+ debugLog(`generateAIMessage: context includes sessionSummary (files=${files}, additions=${additions}, deletions=${deletions})`, config);
97
+ }
98
+ }
99
+
100
+ if (contextParts.length > 0) {
101
+ prompt = `${prompt}\n\nContext for this notification:\n${contextParts.join('\n')}\n\nIncorporate relevant context into your message to make it more specific and helpful (e.g., mention the project name or what was worked on).`;
102
+ debugLog(`generateAIMessage: injected ${contextParts.length} context part(s) into prompt`, config);
103
+ } else {
104
+ debugLog(`generateAIMessage: no context available to inject (projectName, sessionTitle, sessionSummary all empty)`, config);
105
+ }
106
+ } else {
107
+ debugLog(`generateAIMessage: context-aware AI is DISABLED (enableContextAwareAI=${config.enableContextAwareAI})`, config);
42
108
  }
43
109
 
44
110
  try {
@@ -54,6 +120,8 @@ export async function generateAIMessage(promptType, context = {}) {
54
120
  endpoint = endpoint.replace(/\/$/, '') + '/chat/completions';
55
121
  }
56
122
 
123
+ debugLog(`generateAIMessage: sending request to ${endpoint} (model=${config.aiModel || 'llama3'})`, config);
124
+
57
125
  // Create abort controller for timeout
58
126
  const controller = new AbortController();
59
127
  const timeout = setTimeout(() => controller.abort(), config.aiTimeout || 15000);
@@ -83,6 +151,7 @@ export async function generateAIMessage(promptType, context = {}) {
83
151
  clearTimeout(timeout);
84
152
 
85
153
  if (!response.ok) {
154
+ debugLog(`generateAIMessage: API request failed with status ${response.status}`, config);
86
155
  return null;
87
156
  }
88
157
 
@@ -92,6 +161,7 @@ export async function generateAIMessage(promptType, context = {}) {
92
161
  const message = data.choices?.[0]?.message?.content?.trim();
93
162
 
94
163
  if (!message) {
164
+ debugLog(`generateAIMessage: API returned no message content`, config);
95
165
  return null;
96
166
  }
97
167
 
@@ -100,12 +170,15 @@ export async function generateAIMessage(promptType, context = {}) {
100
170
 
101
171
  // Validate message length (sanity check)
102
172
  if (cleanMessage.length < 5 || cleanMessage.length > 200) {
173
+ debugLog(`generateAIMessage: message length invalid (${cleanMessage.length} chars), rejecting`, config);
103
174
  return null;
104
175
  }
105
176
 
177
+ debugLog(`generateAIMessage: SUCCESS - generated message: "${cleanMessage.substring(0, 50)}${cleanMessage.length > 50 ? '...' : ''}"`, config);
106
178
  return cleanMessage;
107
179
 
108
180
  } catch (error) {
181
+ debugLog(`generateAIMessage: ERROR - ${error.name === 'AbortError' ? 'Request timed out' : error.message}`, config);
109
182
  return null;
110
183
  }
111
184
  }
package/util/config.js CHANGED
@@ -24,12 +24,30 @@ const debugLogToFile = (message, configDir) => {
24
24
  };
25
25
 
26
26
  /**
27
- * Basic JSONC parser that strips single-line and multi-line comments.
27
+ * Basic JSONC parser that strips single-line and multi-line comments,
28
+ * and handles trailing commas (which Prettier often adds).
28
29
  * @param {string} jsonc
29
30
  * @returns {any}
30
31
  */
31
- const parseJSONC = (jsonc) => {
32
- const stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m);
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
+
33
51
  return JSON.parse(stripped);
34
52
  };
35
53
 
@@ -39,7 +57,7 @@ const parseJSONC = (jsonc) => {
39
57
  * @param {number} indent
40
58
  * @returns {string}
41
59
  */
42
- const formatJSON = (val, indent = 0) => {
60
+ export const formatJSON = (val, indent = 0) => {
43
61
  const json = JSON.stringify(val, null, 4);
44
62
  return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json;
45
63
  };
@@ -54,7 +72,7 @@ const formatJSON = (val, indent = 0) => {
54
72
  * @param {object} user - The user's existing configuration object
55
73
  * @returns {object} Merged configuration with user values preserved
56
74
  */
57
- const deepMerge = (defaults, user) => {
75
+ export const deepMerge = (defaults, user) => {
58
76
  // If user value doesn't exist, use default
59
77
  if (user === undefined || user === null) {
60
78
  return defaults;
@@ -90,11 +108,20 @@ const deepMerge = (defaults, user) => {
90
108
  * This is the source of truth for all default values.
91
109
  * @returns {object} Default configuration object
92
110
  */
93
- const getDefaultConfigObject = () => ({
111
+ export const getDefaultConfigObject = () => ({
112
+
94
113
  _configVersion: null, // Will be set by caller
95
114
  enabled: true,
96
115
  notificationMode: 'sound-first',
97
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,
98
125
  ttsReminderDelaySeconds: 30,
99
126
  idleReminderDelaySeconds: 30,
100
127
  permissionReminderDelaySeconds: 20,
@@ -195,28 +222,75 @@ const getDefaultConfigObject = () => ({
195
222
  ],
196
223
  questionReminderDelaySeconds: 25,
197
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,
198
254
  enableAIMessages: false,
199
255
  aiEndpoint: 'http://localhost:11434/v1',
200
256
  aiModel: 'llama3',
201
257
  aiApiKey: '',
202
258
  aiTimeout: 15000,
203
259
  aiFallbackToStatic: true,
260
+ enableContextAwareAI: false,
204
261
  aiPrompts: {
205
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.",
206
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.",
207
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.",
208
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.",
209
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.",
210
- 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."
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."
211
270
  },
212
271
  idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3',
213
272
  permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
214
273
  questionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
274
+ errorSound: 'assets/Machine-alert-beep-sound-effect.mp3',
215
275
  wakeMonitor: true,
216
- forceVolume: true,
276
+ forceVolume: false,
217
277
  volumeThreshold: 50,
218
278
  enableToast: true,
219
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,
220
294
  idleThresholdSeconds: 60,
221
295
  debugLog: false
222
296
  });
@@ -229,7 +303,8 @@ const getDefaultConfigObject = () => ({
229
303
  * @param {string} prefix
230
304
  * @returns {string[]} Array of field paths that were added
231
305
  */
232
- const findNewFields = (defaults, user, prefix = '') => {
306
+ export const findNewFields = (defaults, user, prefix = '') => {
307
+
233
308
  const newFields = [];
234
309
 
235
310
  if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
@@ -293,6 +368,25 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
293
368
  // Set to false to disable all notifications without uninstalling.
294
369
  "enabled": ${overrides.enabled !== undefined ? overrides.enabled : true},
295
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
+
296
390
  // ============================================================
297
391
  // NOTIFICATION MODE SETTINGS (Smart Notification System)
298
392
  // ============================================================
@@ -324,10 +418,10 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
324
418
  // ============================================================
325
419
  // TTS ENGINE SELECTION
326
420
  // ============================================================
327
- // 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI)
328
- // 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
329
- // 'edge' - Good quality neural voices (free, requires: pip install edge-tts)
330
- // 'sapi' - Windows built-in voices (free, offline, robotic)
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)
331
425
  "ttsEngine": "${overrides.ttsEngine || 'elevenlabs'}",
332
426
 
333
427
  // Enable TTS for notifications (falls back to sound files if TTS fails)
@@ -556,6 +650,51 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
556
650
  // Question batch window (ms) - how long to wait for more questions before notifying
557
651
  "questionBatchWindowMs": ${overrides.questionBatchWindowMs !== undefined ? overrides.questionBatchWindowMs : 800},
558
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
+
559
698
  // ============================================================
560
699
  // AI MESSAGE GENERATION (OpenAI-Compatible Endpoints)
561
700
  // ============================================================
@@ -592,6 +731,14 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
592
731
  // Fallback to static preset messages if AI generation fails
593
732
  "aiFallbackToStatic": ${overrides.aiFallbackToStatic !== undefined ? overrides.aiFallbackToStatic : true},
594
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
+
595
742
  // Custom prompts for each notification type
596
743
  // The AI will generate a short message based on these prompts
597
744
  // Keep prompts concise - they're sent with each notification
@@ -599,9 +746,11 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
599
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.",
600
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.",
601
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.",
602
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.",
603
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.",
604
- "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."
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."
605
754
  }, 4)},
606
755
 
607
756
  // ============================================================
@@ -615,6 +764,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
615
764
  "idleSound": "${overrides.idleSound || 'assets/Soft-high-tech-notification-sound-effect.mp3'}",
616
765
  "permissionSound": "${overrides.permissionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
617
766
  "questionSound": "${overrides.questionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
767
+ "errorSound": "${overrides.errorSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
618
768
 
619
769
  // ============================================================
620
770
  // GENERAL SETTINGS
@@ -624,7 +774,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
624
774
  "wakeMonitor": ${overrides.wakeMonitor !== undefined ? overrides.wakeMonitor : true},
625
775
 
626
776
  // Force system volume up if below threshold
627
- "forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : true},
777
+ "forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : false},
628
778
 
629
779
  // Volume threshold (0-100): force volume if current level is below this
630
780
  "volumeThreshold": ${overrides.volumeThreshold !== undefined ? overrides.volumeThreshold : 50},
@@ -635,6 +785,109 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
635
785
  // Enable audio notifications (sound files and TTS)
636
786
  "enableSound": ${overrides.enableSound !== undefined ? overrides.enableSound : true},
637
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
+
638
891
  // Consider monitor asleep after this many seconds of inactivity (Windows only)
639
892
  "idleThresholdSeconds": ${overrides.idleThresholdSeconds !== undefined ? overrides.idleThresholdSeconds : 60},
640
893
 
@@ -701,6 +954,10 @@ export const loadConfig = (name, defaults = {}) => {
701
954
  const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8'));
702
955
  const currentVersion = pkg.version;
703
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
+
704
961
  // Always ensure bundled assets are present
705
962
  copyBundledAssets(configDir);
706
963
 
@@ -711,28 +968,50 @@ export const loadConfig = (name, defaults = {}) => {
711
968
  const content = fs.readFileSync(filePath, 'utf-8');
712
969
  existingConfig = parseJSONC(content);
713
970
  } catch (error) {
714
- // If file is invalid JSONC, we'll create a fresh one
715
- debugLogToFile(`Config file was invalid (${error.message}), creating fresh config`, configDir);
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
+ }
716
994
  }
717
- }
718
995
 
719
- // Get default config object with current version
720
- const defaultConfig = getDefaultConfigObject();
721
- defaultConfig._configVersion = currentVersion;
996
+ }
722
997
 
723
- // CASE 1: No existing config - create new file with full documentation
998
+ // CASE 1: No existing config (missing or invalid)
724
999
  if (!existingConfig) {
1000
+
725
1001
  try {
726
1002
  // Ensure config directory exists
727
1003
  if (!fs.existsSync(configDir)) {
728
1004
  fs.mkdirSync(configDir, { recursive: true });
729
1005
  }
730
1006
 
731
- // Generate new config file with all documentation comments
732
- const newConfigContent = generateDefaultConfig({}, currentVersion);
733
- fs.writeFileSync(filePath, newConfigContent, 'utf-8');
734
-
735
- debugLogToFile(`Initialized default config at ${filePath}`, configDir);
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
+ }
736
1015
 
737
1016
  // Return the default config merged with any passed defaults
738
1017
  return { ...defaults, ...defaultConfig };
@@ -742,6 +1021,7 @@ export const loadConfig = (name, defaults = {}) => {
742
1021
  }
743
1022
  }
744
1023
 
1024
+
745
1025
  // CASE 2: Existing config - smart merge to add new fields only
746
1026
  // Find what new fields need to be added (for logging)
747
1027
  const newFields = findNewFields(defaultConfig, existingConfig);