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.
- package/package.json +1 -1
- 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
|
+
"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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
};
|