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/LICENSE +21 -21
- package/README.md +254 -66
- package/example.config.jsonc +135 -122
- package/index.js +541 -51
- package/package.json +59 -52
- package/util/ai-messages.js +73 -0
- package/util/config.js +653 -335
- package/util/desktop-notify.js +319 -0
- package/util/focus-detect.js +372 -0
- package/util/per-project-sound.js +90 -0
- package/util/sound-theme.js +129 -0
- package/util/tts.js +684 -596
- package/util/webhook.js +743 -0
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
152
|
-
"
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"Still waiting for
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"Hey! I
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"I need
|
|
167
|
-
"
|
|
168
|
-
],
|
|
169
|
-
|
|
170
|
-
"Hey! I
|
|
171
|
-
"
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
"
|
|
175
|
-
],
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
"
|
|
179
|
-
"
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 :
|
|
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
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// CASE
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
+
};
|