opencode-smart-voice-notify 1.2.3 → 1.2.4

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/util/config.js +330 -73
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-smart-voice-notify",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
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",
package/util/config.js CHANGED
@@ -1,7 +1,27 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import { fileURLToPath } from 'url';
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
+ };
5
25
 
6
26
  /**
7
27
  * Basic JSONC parser that strips single-line and multi-line comments.
@@ -13,16 +33,217 @@ const parseJSONC = (jsonc) => {
13
33
  return JSON.parse(stripped);
14
34
  };
15
35
 
16
- /**
17
- * Helper to format JSON values for the template.
18
- * @param {any} val
19
- * @param {number} indent
20
- * @returns {string}
21
- */
22
- const formatJSON = (val, indent = 0) => {
23
- const json = JSON.stringify(val, null, 4);
24
- return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json;
25
- };
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
+ };
26
247
 
27
248
  /**
28
249
  * Get the directory where this plugin is installed.
@@ -422,62 +643,98 @@ const copyBundledAssets = (configDir) => {
422
643
  }
423
644
  };
424
645
 
425
- /**
426
- * Loads a configuration file from the OpenCode config directory.
427
- * If the file doesn't exist, creates a default config file.
428
- * Performs version checks and migrates config if necessary.
429
- * @param {string} name - Name of the config file (without .jsonc extension)
430
- * @param {object} defaults - Default values if file doesn't exist or is invalid
431
- * @returns {object}
432
- */
433
- export const loadConfig = (name, defaults = {}) => {
434
- const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
435
- const filePath = path.join(configDir, `${name}.jsonc`);
436
-
437
- // Get current version from package.json
438
- const pluginDir = getPluginDir();
439
- const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8'));
440
- const currentVersion = pkg.version;
441
-
442
- let existingConfig = null;
443
- if (fs.existsSync(filePath)) {
444
- try {
445
- const content = fs.readFileSync(filePath, 'utf-8');
446
- existingConfig = parseJSONC(content);
447
- } catch (error) {
448
- // If file is invalid JSONC, we'll treat it as missing and overwrite
449
- }
450
- }
451
-
452
- // Version check and migration logic
453
- if (!existingConfig || existingConfig._configVersion !== currentVersion) {
454
- try {
455
- // Ensure config directory exists
456
- if (!fs.existsSync(configDir)) {
457
- fs.mkdirSync(configDir, { recursive: true });
458
- }
459
-
460
- // Generate new config content using existing values as overrides
461
- // This preserves user settings while updating comments and adding new fields
462
- const newConfigContent = generateDefaultConfig(existingConfig || {}, currentVersion);
463
- fs.writeFileSync(filePath, newConfigContent, 'utf-8');
464
-
465
- // Also ensure all bundled assets (sound files) are present in the config directory
466
- copyBundledAssets(configDir);
467
-
468
- if (existingConfig) {
469
- console.log(`[Smart Voice Notify] Config migrated to version ${currentVersion}`);
470
- } else {
471
- console.log(`[Smart Voice Notify] Initialized default config at ${filePath}`);
472
- }
473
-
474
- // Re-parse the newly written config
475
- existingConfig = parseJSONC(newConfigContent);
476
- } catch (error) {
477
- // If migration fails, try to return whatever we have or defaults
478
- return existingConfig || defaults;
479
- }
480
- }
481
-
482
- return { ...defaults, ...existingConfig };
483
- };
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
+ };