opencode-smart-voice-notify 1.2.4 → 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/util/config.js CHANGED
@@ -1,249 +1,330 @@
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
- };
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
25
5
 
26
6
  /**
27
- * Basic JSONC parser that strips single-line and multi-line comments.
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).
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
 
36
- /**
37
- * Helper to format JSON values for the template.
38
- * @param {any} val
39
- * @param {number} indent
40
- * @returns {string}
41
- */
42
- const formatJSON = (val, indent = 0) => {
43
- const json = JSON.stringify(val, null, 4);
44
- return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json;
45
- };
46
-
47
- /**
48
- * Deep merge two objects. User values take precedence over defaults.
49
- * - For objects: recursively merge, adding new keys from defaults
50
- * - For arrays: user's array completely replaces default (no merge)
51
- * - For primitives: user's value takes precedence if it exists
52
- *
53
- * @param {object} defaults - The default configuration object
54
- * @param {object} user - The user's existing configuration object
55
- * @returns {object} Merged configuration with user values preserved
56
- */
57
- const deepMerge = (defaults, user) => {
58
- // If user value doesn't exist, use default
59
- if (user === undefined || user === null) {
60
- return defaults;
61
- }
62
-
63
- // If either is not an object (or is array), user value wins
64
- if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
65
- return user;
66
- }
67
- if (typeof user !== 'object' || user === null || Array.isArray(user)) {
68
- return user;
69
- }
70
-
71
- // Both are objects - merge them
72
- const result = { ...user };
73
-
74
- for (const key of Object.keys(defaults)) {
75
- if (!(key in user)) {
76
- // Key doesn't exist in user config - add it from defaults
77
- result[key] = defaults[key];
78
- } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
79
- // Both have this key and it's an object - recurse
80
- result[key] = deepMerge(defaults[key], user[key]);
81
- }
82
- // else: user has this key and it's not an object to merge - keep user's value
83
- }
84
-
85
- return result;
86
- };
87
-
88
- /**
89
- * Get the default configuration object.
90
- * This is the source of truth for all default values.
91
- * @returns {object} Default configuration object
92
- */
93
- const getDefaultConfigObject = () => ({
94
- _configVersion: null, // Will be set by caller
95
- enabled: true,
96
- notificationMode: 'sound-first',
97
- enableTTSReminder: true,
98
- ttsReminderDelaySeconds: 30,
99
- idleReminderDelaySeconds: 30,
100
- permissionReminderDelaySeconds: 20,
101
- enableFollowUpReminders: true,
102
- maxFollowUpReminders: 3,
103
- reminderBackoffMultiplier: 1.5,
104
- ttsEngine: 'elevenlabs',
105
- enableTTS: true,
106
- // elevenLabsApiKey is intentionally omitted - users must set it
107
- elevenLabsVoiceId: 'cgSgspJ2msm6clMCkdW9',
108
- elevenLabsModel: 'eleven_turbo_v2_5',
109
- elevenLabsStability: 0.5,
110
- elevenLabsSimilarity: 0.75,
111
- elevenLabsStyle: 0.5,
112
- edgeVoice: 'en-US-JennyNeural',
113
- edgePitch: '+0Hz',
114
- edgeRate: '+10%',
115
- sapiVoice: 'Microsoft Zira Desktop',
116
- sapiRate: -1,
117
- sapiPitch: 'medium',
118
- sapiVolume: 'loud',
119
- idleTTSMessages: [
120
- "All done! Your task has been completed successfully.",
121
- "Hey there! I finished working on your request.",
122
- "Task complete! Ready for your review whenever you are.",
123
- "Good news! Everything is done and ready for you.",
124
- "Finished! Let me know if you need anything else."
125
- ],
126
- permissionTTSMessages: [
127
- "Attention please! I need your permission to continue.",
128
- "Hey! Quick approval needed to proceed with the task.",
129
- "Heads up! There is a permission request waiting for you.",
130
- "Excuse me! I need your authorization before I can continue.",
131
- "Permission required! Please review and approve when ready."
132
- ],
133
- permissionTTSMessagesMultiple: [
134
- "Attention please! There are {count} permission requests waiting for your approval.",
135
- "Hey! {count} permissions need your approval to continue.",
136
- "Heads up! You have {count} pending permission requests.",
137
- "Excuse me! I need your authorization for {count} different actions.",
138
- "{count} permissions required! Please review and approve when ready."
139
- ],
140
- idleReminderTTSMessages: [
141
- "Hey, are you still there? Your task has been waiting for review.",
142
- "Just a gentle reminder - I finished your request a while ago!",
143
- "Hello? I completed your task. Please take a look when you can.",
144
- "Still waiting for you! The work is done and ready for review.",
145
- "Knock knock! Your completed task is patiently waiting for you."
146
- ],
147
- permissionReminderTTSMessages: [
148
- "Hey! I still need your permission to continue. Please respond!",
149
- "Reminder: There is a pending permission request. I cannot proceed without you.",
150
- "Hello? I am waiting for your approval. This is getting urgent!",
151
- "Please check your screen! I really need your permission to move forward.",
152
- "Still waiting for authorization! The task is on hold until you respond."
153
- ],
154
- permissionReminderTTSMessagesMultiple: [
155
- "Hey! I still need your approval for {count} permissions. Please respond!",
156
- "Reminder: There are {count} pending permission requests. I cannot proceed without you.",
157
- "Hello? I am waiting for your approval on {count} items. This is getting urgent!",
158
- "Please check your screen! {count} permissions are waiting for your response.",
159
- "Still waiting for authorization on {count} requests! The task is on hold."
160
- ],
161
- permissionBatchWindowMs: 800,
162
- questionTTSMessages: [
163
- "Hey! I have a question for you. Please check your screen.",
164
- "Attention! I need your input to continue.",
165
- "Quick question! Please take a look when you have a moment.",
166
- "I need some clarification. Could you please respond?",
167
- "Question time! Your input is needed to proceed."
168
- ],
169
- questionTTSMessagesMultiple: [
170
- "Hey! I have {count} questions for you. Please check your screen.",
171
- "Attention! I need your input on {count} items to continue.",
172
- "{count} questions need your attention. Please take a look!",
173
- "I need some clarifications. There are {count} questions waiting for you.",
174
- "Question time! {count} questions need your response to proceed."
175
- ],
176
- questionReminderTTSMessages: [
177
- "Hey! I am still waiting for your answer. Please check the questions!",
178
- "Reminder: There is a question waiting for your response.",
179
- "Hello? I need your input to continue. Please respond when you can.",
180
- "Still waiting for your answer! The task is on hold.",
181
- "Your input is needed! Please check the pending question."
182
- ],
183
- questionReminderTTSMessagesMultiple: [
184
- "Hey! I am still waiting for answers to {count} questions. Please respond!",
185
- "Reminder: There are {count} questions waiting for your response.",
186
- "Hello? I need your input on {count} items. Please respond when you can.",
187
- "Still waiting for your answers on {count} questions! The task is on hold.",
188
- "Your input is needed! {count} questions are pending your response."
189
- ],
190
- questionReminderDelaySeconds: 25,
191
- questionBatchWindowMs: 800,
192
- enableAIMessages: false,
193
- aiEndpoint: 'http://localhost:11434/v1',
194
- aiModel: 'llama3',
195
- aiApiKey: '',
196
- aiTimeout: 15000,
197
- aiFallbackToStatic: true,
198
- aiPrompts: {
199
- 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.",
200
- 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.",
201
- 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.",
202
- 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.",
203
- 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.",
204
- 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."
205
- },
206
- idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3',
207
- permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
208
- questionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
209
- wakeMonitor: true,
210
- forceVolume: true,
211
- volumeThreshold: 50,
212
- enableToast: true,
213
- enableSound: true,
214
- idleThresholdSeconds: 60,
215
- debugLog: false
216
- });
217
-
218
- /**
219
- * Find new fields that exist in defaults but not in user config.
220
- * Used for logging what was added during migration.
221
- * @param {object} defaults
222
- * @param {object} user
223
- * @param {string} prefix
224
- * @returns {string[]} Array of field paths that were added
225
- */
226
- const findNewFields = (defaults, user, prefix = '') => {
227
- const newFields = [];
228
-
229
- if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
230
- return newFields;
231
- }
232
-
233
- for (const key of Object.keys(defaults)) {
234
- const fieldPath = prefix ? `${prefix}.${key}` : key;
235
-
236
- if (!(key in user)) {
237
- newFields.push(fieldPath);
238
- } else if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
239
- if (typeof user[key] === 'object' && user[key] !== null && !Array.isArray(user[key])) {
240
- newFields.push(...findNewFields(defaults[key], user[key], fieldPath));
241
- }
242
- }
243
- }
244
-
245
- return newFields;
246
- };
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
+ };
247
328
 
248
329
  /**
249
330
  * Get the directory where this plugin is installed.
@@ -287,6 +368,25 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
287
368
  // Set to false to disable all notifications without uninstalling.
288
369
  "enabled": ${overrides.enabled !== undefined ? overrides.enabled : true},
289
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
+
290
390
  // ============================================================
291
391
  // NOTIFICATION MODE SETTINGS (Smart Notification System)
292
392
  // ============================================================
@@ -318,6 +418,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
318
418
  // ============================================================
319
419
  // TTS ENGINE SELECTION
320
420
  // ============================================================
421
+ // 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI)
321
422
  // 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
322
423
  // 'edge' - Good quality neural voices (free, requires: pip install edge-tts)
323
424
  // 'sapi' - Windows built-in voices (free, offline, robotic)
@@ -395,6 +496,37 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
395
496
  // Volume: 'silent', 'x-soft', 'soft', 'medium', 'loud', 'x-loud'
396
497
  "sapiVolume": "${overrides.sapiVolume || 'loud'}",
397
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
+
398
530
  // ============================================================
399
531
  // INITIAL TTS MESSAGES (Used immediately or after sound)
400
532
  // These are randomly selected each time for variety
@@ -518,6 +650,51 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
518
650
  // Question batch window (ms) - how long to wait for more questions before notifying
519
651
  "questionBatchWindowMs": ${overrides.questionBatchWindowMs !== undefined ? overrides.questionBatchWindowMs : 800},
520
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
+
521
698
  // ============================================================
522
699
  // AI MESSAGE GENERATION (OpenAI-Compatible Endpoints)
523
700
  // ============================================================
@@ -554,6 +731,14 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
554
731
  // Fallback to static preset messages if AI generation fails
555
732
  "aiFallbackToStatic": ${overrides.aiFallbackToStatic !== undefined ? overrides.aiFallbackToStatic : true},
556
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
+
557
742
  // Custom prompts for each notification type
558
743
  // The AI will generate a short message based on these prompts
559
744
  // Keep prompts concise - they're sent with each notification
@@ -561,9 +746,11 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
561
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.",
562
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.",
563
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.",
564
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.",
565
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.",
566
- "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."
567
754
  }, 4)},
568
755
 
569
756
  // ============================================================
@@ -577,6 +764,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
577
764
  "idleSound": "${overrides.idleSound || 'assets/Soft-high-tech-notification-sound-effect.mp3'}",
578
765
  "permissionSound": "${overrides.permissionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
579
766
  "questionSound": "${overrides.questionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
767
+ "errorSound": "${overrides.errorSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
580
768
 
581
769
  // ============================================================
582
770
  // GENERAL SETTINGS
@@ -586,7 +774,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
586
774
  "wakeMonitor": ${overrides.wakeMonitor !== undefined ? overrides.wakeMonitor : true},
587
775
 
588
776
  // Force system volume up if below threshold
589
- "forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : true},
777
+ "forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : false},
590
778
 
591
779
  // Volume threshold (0-100): force volume if current level is below this
592
780
  "volumeThreshold": ${overrides.volumeThreshold !== undefined ? overrides.volumeThreshold : 50},
@@ -597,6 +785,109 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
597
785
  // Enable audio notifications (sound files and TTS)
598
786
  "enableSound": ${overrides.enableSound !== undefined ? overrides.enableSound : true},
599
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
+
600
891
  // Consider monitor asleep after this many seconds of inactivity (Windows only)
601
892
  "idleThresholdSeconds": ${overrides.idleThresholdSeconds !== undefined ? overrides.idleThresholdSeconds : 60},
602
893
 
@@ -643,98 +934,125 @@ const copyBundledAssets = (configDir) => {
643
934
  }
644
935
  };
645
936
 
646
- /**
647
- * Loads a configuration file from the OpenCode config directory.
648
- * If the file doesn't exist, creates a default config file with full documentation.
649
- * If the file exists, performs smart merging to add new fields without overwriting user values.
650
- *
651
- * IMPORTANT: User values are NEVER overwritten. Only new fields from plugin updates are added.
652
- *
653
- * @param {string} name - Name of the config file (without .jsonc extension)
654
- * @param {object} defaults - Default values if file doesn't exist or is invalid
655
- * @returns {object}
656
- */
657
- export const loadConfig = (name, defaults = {}) => {
658
- const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
659
- const filePath = path.join(configDir, `${name}.jsonc`);
660
-
661
- // Get current version from package.json
662
- const pluginDir = getPluginDir();
663
- const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8'));
664
- const currentVersion = pkg.version;
665
-
666
- // Always ensure bundled assets are present
667
- copyBundledAssets(configDir);
668
-
669
- // Try to load existing config
670
- let existingConfig = null;
671
- if (fs.existsSync(filePath)) {
672
- try {
673
- const content = fs.readFileSync(filePath, 'utf-8');
674
- existingConfig = parseJSONC(content);
675
- } catch (error) {
676
- // If file is invalid JSONC, we'll create a fresh one
677
- debugLogToFile(`Config file was invalid (${error.message}), creating fresh config`, configDir);
678
- }
679
- }
680
-
681
- // Get default config object with current version
682
- const defaultConfig = getDefaultConfigObject();
683
- defaultConfig._configVersion = currentVersion;
684
-
685
- // CASE 1: No existing config - create new file with full documentation
686
- if (!existingConfig) {
687
- try {
688
- // Ensure config directory exists
689
- if (!fs.existsSync(configDir)) {
690
- fs.mkdirSync(configDir, { recursive: true });
691
- }
692
-
693
- // Generate new config file with all documentation comments
694
- const newConfigContent = generateDefaultConfig({}, currentVersion);
695
- fs.writeFileSync(filePath, newConfigContent, 'utf-8');
696
-
697
- debugLogToFile(`Initialized default config at ${filePath}`, configDir);
698
-
699
- // Return the default config merged with any passed defaults
700
- return { ...defaults, ...defaultConfig };
701
- } catch (error) {
702
- // If creation fails, return defaults
703
- return { ...defaults, ...defaultConfig };
704
- }
705
- }
706
-
707
- // CASE 2: Existing config - smart merge to add new fields only
708
- // Find what new fields need to be added (for logging)
709
- const newFields = findNewFields(defaultConfig, existingConfig);
710
-
711
- // Deep merge: user values preserved, only new fields added from defaults
712
- const mergedConfig = deepMerge(defaultConfig, existingConfig);
713
-
714
- // Update version in merged config
715
- mergedConfig._configVersion = currentVersion;
716
-
717
- // Only write back if there are new fields to add OR version changed
718
- const versionChanged = existingConfig._configVersion !== currentVersion;
719
-
720
- if (newFields.length > 0 || versionChanged) {
721
- try {
722
- // Regenerate the config file with full documentation comments
723
- // Pass the merged config so user values are preserved in the output
724
- const newConfigContent = generateDefaultConfig(mergedConfig, currentVersion);
725
- fs.writeFileSync(filePath, newConfigContent, 'utf-8');
726
-
727
- if (newFields.length > 0) {
728
- debugLogToFile(`Added ${newFields.length} new config field(s): ${newFields.join(', ')}`, configDir);
729
- }
730
- if (versionChanged) {
731
- debugLogToFile(`Config version updated to ${currentVersion}`, configDir);
732
- }
733
- } catch (error) {
734
- // If write fails, still return the merged config (just won't persist new fields)
735
- debugLogToFile(`Warning: Could not update config file: ${error.message}`, configDir);
736
- }
737
- }
738
-
739
- return { ...defaults, ...mergedConfig };
740
- };
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
+ };