opencode-smart-voice-notify 1.2.5 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +178 -25
- package/example.config.jsonc +139 -158
- package/index.js +541 -51
- package/package.json +10 -3
- package/util/ai-messages.js +73 -0
- package/util/config.js +307 -27
- 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 +26 -8
- package/util/webhook.js +743 -0
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-smart-voice-notify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI), AI-generated dynamic messages, and intelligent reminder system",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test",
|
|
9
|
+
"test:watch": "bun test --watch",
|
|
10
|
+
"test:coverage": "bun test --coverage"
|
|
11
|
+
},
|
|
7
12
|
"author": "MasuRii",
|
|
8
13
|
"license": "MIT",
|
|
9
14
|
"keywords": [
|
|
@@ -43,8 +48,10 @@
|
|
|
43
48
|
"bun": ">=1.0.0"
|
|
44
49
|
},
|
|
45
50
|
"dependencies": {
|
|
46
|
-
"@elevenlabs/elevenlabs-js": "^2.
|
|
47
|
-
"
|
|
51
|
+
"@elevenlabs/elevenlabs-js": "^2.32.0",
|
|
52
|
+
"detect-terminal": "^2.0.0",
|
|
53
|
+
"msedge-tts": "^2.0.3",
|
|
54
|
+
"node-notifier": "^10.0.1"
|
|
48
55
|
},
|
|
49
56
|
"peerDependencies": {
|
|
50
57
|
"@opencode-ai/plugin": "^1.1.8"
|
package/util/ai-messages.js
CHANGED
|
@@ -7,8 +7,33 @@
|
|
|
7
7
|
* Uses native fetch() - no external dependencies required.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
10
13
|
import { getTTSConfig } from './tts.js';
|
|
11
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Debug logging to file (no console output).
|
|
17
|
+
* Logs are written to ~/.config/opencode/logs/smart-voice-notify-debug.log
|
|
18
|
+
* @param {string} message - Message to log
|
|
19
|
+
* @param {object} config - Config object with debugLog flag
|
|
20
|
+
*/
|
|
21
|
+
const debugLog = (message, config) => {
|
|
22
|
+
if (!config?.debugLog) return;
|
|
23
|
+
try {
|
|
24
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
25
|
+
const logsDir = path.join(configDir, 'logs');
|
|
26
|
+
if (!fs.existsSync(logsDir)) {
|
|
27
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
30
|
+
const timestamp = new Date().toISOString();
|
|
31
|
+
fs.appendFileSync(logFile, `[${timestamp}] [ai-messages] ${message}\n`);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// Silently fail - logging should never break the plugin
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
12
37
|
/**
|
|
13
38
|
* Generate a message using an OpenAI-compatible AI endpoint
|
|
14
39
|
* @param {string} promptType - The type of prompt ('idle', 'permission', 'question', 'idleReminder', 'permissionReminder', 'questionReminder')
|
|
@@ -23,9 +48,12 @@ export async function generateAIMessage(promptType, context = {}) {
|
|
|
23
48
|
return null;
|
|
24
49
|
}
|
|
25
50
|
|
|
51
|
+
debugLog(`generateAIMessage: starting for promptType="${promptType}"`, config);
|
|
52
|
+
|
|
26
53
|
// Get the prompt for this type
|
|
27
54
|
let prompt = config.aiPrompts?.[promptType];
|
|
28
55
|
if (!prompt) {
|
|
56
|
+
debugLog(`generateAIMessage: no prompt found for type "${promptType}"`, config);
|
|
29
57
|
return null;
|
|
30
58
|
}
|
|
31
59
|
|
|
@@ -39,6 +67,44 @@ export async function generateAIMessage(promptType, context = {}) {
|
|
|
39
67
|
itemType = 'permission requests';
|
|
40
68
|
}
|
|
41
69
|
prompt = `${prompt} Important: There are ${context.count} ${itemType} (not just one) waiting for the user's attention. Mention the count in your message.`;
|
|
70
|
+
debugLog(`generateAIMessage: injected count context (count=${context.count}, type=${context.type})`, config);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Inject session/project context if context-aware AI is enabled
|
|
74
|
+
if (config.enableContextAwareAI) {
|
|
75
|
+
debugLog(`generateAIMessage: context-aware AI is ENABLED`, config);
|
|
76
|
+
const contextParts = [];
|
|
77
|
+
|
|
78
|
+
if (context.projectName) {
|
|
79
|
+
contextParts.push(`Project: "${context.projectName}"`);
|
|
80
|
+
debugLog(`generateAIMessage: context includes projectName="${context.projectName}"`, config);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (context.sessionTitle) {
|
|
84
|
+
contextParts.push(`Task: "${context.sessionTitle}"`);
|
|
85
|
+
debugLog(`generateAIMessage: context includes sessionTitle="${context.sessionTitle}"`, config);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (context.sessionSummary) {
|
|
89
|
+
const { files, additions, deletions } = context.sessionSummary;
|
|
90
|
+
if (files !== undefined || additions !== undefined || deletions !== undefined) {
|
|
91
|
+
const summaryParts = [];
|
|
92
|
+
if (files !== undefined) summaryParts.push(`${files} file(s) modified`);
|
|
93
|
+
if (additions !== undefined) summaryParts.push(`+${additions} lines`);
|
|
94
|
+
if (deletions !== undefined) summaryParts.push(`-${deletions} lines`);
|
|
95
|
+
contextParts.push(`Changes: ${summaryParts.join(', ')}`);
|
|
96
|
+
debugLog(`generateAIMessage: context includes sessionSummary (files=${files}, additions=${additions}, deletions=${deletions})`, config);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (contextParts.length > 0) {
|
|
101
|
+
prompt = `${prompt}\n\nContext for this notification:\n${contextParts.join('\n')}\n\nIncorporate relevant context into your message to make it more specific and helpful (e.g., mention the project name or what was worked on).`;
|
|
102
|
+
debugLog(`generateAIMessage: injected ${contextParts.length} context part(s) into prompt`, config);
|
|
103
|
+
} else {
|
|
104
|
+
debugLog(`generateAIMessage: no context available to inject (projectName, sessionTitle, sessionSummary all empty)`, config);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
debugLog(`generateAIMessage: context-aware AI is DISABLED (enableContextAwareAI=${config.enableContextAwareAI})`, config);
|
|
42
108
|
}
|
|
43
109
|
|
|
44
110
|
try {
|
|
@@ -54,6 +120,8 @@ export async function generateAIMessage(promptType, context = {}) {
|
|
|
54
120
|
endpoint = endpoint.replace(/\/$/, '') + '/chat/completions';
|
|
55
121
|
}
|
|
56
122
|
|
|
123
|
+
debugLog(`generateAIMessage: sending request to ${endpoint} (model=${config.aiModel || 'llama3'})`, config);
|
|
124
|
+
|
|
57
125
|
// Create abort controller for timeout
|
|
58
126
|
const controller = new AbortController();
|
|
59
127
|
const timeout = setTimeout(() => controller.abort(), config.aiTimeout || 15000);
|
|
@@ -83,6 +151,7 @@ export async function generateAIMessage(promptType, context = {}) {
|
|
|
83
151
|
clearTimeout(timeout);
|
|
84
152
|
|
|
85
153
|
if (!response.ok) {
|
|
154
|
+
debugLog(`generateAIMessage: API request failed with status ${response.status}`, config);
|
|
86
155
|
return null;
|
|
87
156
|
}
|
|
88
157
|
|
|
@@ -92,6 +161,7 @@ export async function generateAIMessage(promptType, context = {}) {
|
|
|
92
161
|
const message = data.choices?.[0]?.message?.content?.trim();
|
|
93
162
|
|
|
94
163
|
if (!message) {
|
|
164
|
+
debugLog(`generateAIMessage: API returned no message content`, config);
|
|
95
165
|
return null;
|
|
96
166
|
}
|
|
97
167
|
|
|
@@ -100,12 +170,15 @@ export async function generateAIMessage(promptType, context = {}) {
|
|
|
100
170
|
|
|
101
171
|
// Validate message length (sanity check)
|
|
102
172
|
if (cleanMessage.length < 5 || cleanMessage.length > 200) {
|
|
173
|
+
debugLog(`generateAIMessage: message length invalid (${cleanMessage.length} chars), rejecting`, config);
|
|
103
174
|
return null;
|
|
104
175
|
}
|
|
105
176
|
|
|
177
|
+
debugLog(`generateAIMessage: SUCCESS - generated message: "${cleanMessage.substring(0, 50)}${cleanMessage.length > 50 ? '...' : ''}"`, config);
|
|
106
178
|
return cleanMessage;
|
|
107
179
|
|
|
108
180
|
} catch (error) {
|
|
181
|
+
debugLog(`generateAIMessage: ERROR - ${error.name === 'AbortError' ? 'Request timed out' : error.message}`, config);
|
|
109
182
|
return null;
|
|
110
183
|
}
|
|
111
184
|
}
|
package/util/config.js
CHANGED
|
@@ -24,12 +24,30 @@ const debugLogToFile = (message, configDir) => {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Basic JSONC parser that strips single-line and multi-line comments
|
|
27
|
+
* Basic JSONC parser that strips single-line and multi-line comments,
|
|
28
|
+
* and handles trailing commas (which Prettier often adds).
|
|
28
29
|
* @param {string} jsonc
|
|
29
30
|
* @returns {any}
|
|
30
31
|
*/
|
|
31
|
-
const parseJSONC = (jsonc) => {
|
|
32
|
-
|
|
32
|
+
export const parseJSONC = (jsonc) => {
|
|
33
|
+
// Step 1: Strip comments while preserving strings
|
|
34
|
+
// This regex matches strings (handling escaped quotes) or comments
|
|
35
|
+
// If it's a comment, we replace it with empty string
|
|
36
|
+
let stripped = jsonc.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m);
|
|
37
|
+
|
|
38
|
+
// Step 2: Strip trailing commas (e.g. [1, 2,] or {"a":1,})
|
|
39
|
+
// This helps when formatters like Prettier are used
|
|
40
|
+
stripped = stripped.replace(/,(\s*[\]}])/g, '$1');
|
|
41
|
+
|
|
42
|
+
// Step 3: Handle literal control characters that might be present
|
|
43
|
+
// JSON.parse fails on literal control characters (U+0000 to U+001F).
|
|
44
|
+
// Some are allowed as whitespace (space, tab, newline, cr), but literal
|
|
45
|
+
// tabs or newlines INSIDE strings are strictly forbidden.
|
|
46
|
+
// We'll strip most of them, but preserve allowed whitespace outside strings.
|
|
47
|
+
// A safer approach for user-edited files is to remove characters that
|
|
48
|
+
// definitely shouldn't be there.
|
|
49
|
+
stripped = stripped.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
50
|
+
|
|
33
51
|
return JSON.parse(stripped);
|
|
34
52
|
};
|
|
35
53
|
|
|
@@ -39,7 +57,7 @@ const parseJSONC = (jsonc) => {
|
|
|
39
57
|
* @param {number} indent
|
|
40
58
|
* @returns {string}
|
|
41
59
|
*/
|
|
42
|
-
const formatJSON = (val, indent = 0) => {
|
|
60
|
+
export const formatJSON = (val, indent = 0) => {
|
|
43
61
|
const json = JSON.stringify(val, null, 4);
|
|
44
62
|
return indent > 0 ? json.replace(/\n/g, '\n' + ' '.repeat(indent)) : json;
|
|
45
63
|
};
|
|
@@ -54,7 +72,7 @@ const formatJSON = (val, indent = 0) => {
|
|
|
54
72
|
* @param {object} user - The user's existing configuration object
|
|
55
73
|
* @returns {object} Merged configuration with user values preserved
|
|
56
74
|
*/
|
|
57
|
-
const deepMerge = (defaults, user) => {
|
|
75
|
+
export const deepMerge = (defaults, user) => {
|
|
58
76
|
// If user value doesn't exist, use default
|
|
59
77
|
if (user === undefined || user === null) {
|
|
60
78
|
return defaults;
|
|
@@ -90,11 +108,20 @@ const deepMerge = (defaults, user) => {
|
|
|
90
108
|
* This is the source of truth for all default values.
|
|
91
109
|
* @returns {object} Default configuration object
|
|
92
110
|
*/
|
|
93
|
-
const getDefaultConfigObject = () => ({
|
|
111
|
+
export const getDefaultConfigObject = () => ({
|
|
112
|
+
|
|
94
113
|
_configVersion: null, // Will be set by caller
|
|
95
114
|
enabled: true,
|
|
96
115
|
notificationMode: 'sound-first',
|
|
97
116
|
enableTTSReminder: true,
|
|
117
|
+
enableIdleNotification: true,
|
|
118
|
+
enablePermissionNotification: true,
|
|
119
|
+
enableQuestionNotification: true,
|
|
120
|
+
enableErrorNotification: false,
|
|
121
|
+
enableIdleReminder: true,
|
|
122
|
+
enablePermissionReminder: true,
|
|
123
|
+
enableQuestionReminder: true,
|
|
124
|
+
enableErrorReminder: false,
|
|
98
125
|
ttsReminderDelaySeconds: 30,
|
|
99
126
|
idleReminderDelaySeconds: 30,
|
|
100
127
|
permissionReminderDelaySeconds: 20,
|
|
@@ -195,28 +222,75 @@ const getDefaultConfigObject = () => ({
|
|
|
195
222
|
],
|
|
196
223
|
questionReminderDelaySeconds: 25,
|
|
197
224
|
questionBatchWindowMs: 800,
|
|
225
|
+
errorTTSMessages: [
|
|
226
|
+
"Oops! Something went wrong. Please check for errors.",
|
|
227
|
+
"Alert! The agent encountered an error and needs your attention.",
|
|
228
|
+
"Error detected! Please review the issue when you can.",
|
|
229
|
+
"Houston, we have a problem! An error occurred during the task.",
|
|
230
|
+
"Heads up! There was an error that requires your attention."
|
|
231
|
+
],
|
|
232
|
+
errorTTSMessagesMultiple: [
|
|
233
|
+
"Oops! There are {count} errors that need your attention.",
|
|
234
|
+
"Alert! The agent encountered {count} errors. Please review.",
|
|
235
|
+
"{count} errors detected! Please check when you can.",
|
|
236
|
+
"Houston, we have {count} problems! Multiple errors occurred.",
|
|
237
|
+
"Heads up! {count} errors require your attention."
|
|
238
|
+
],
|
|
239
|
+
errorReminderTTSMessages: [
|
|
240
|
+
"Hey! There's still an error waiting for your attention.",
|
|
241
|
+
"Reminder: An error occurred and hasn't been addressed yet.",
|
|
242
|
+
"The agent is stuck! Please check the error when you can.",
|
|
243
|
+
"Still waiting! That error needs your attention.",
|
|
244
|
+
"Don't forget! There's an unresolved error in your session."
|
|
245
|
+
],
|
|
246
|
+
errorReminderTTSMessagesMultiple: [
|
|
247
|
+
"Hey! There are still {count} errors waiting for your attention.",
|
|
248
|
+
"Reminder: {count} errors occurred and haven't been addressed yet.",
|
|
249
|
+
"The agent is stuck! Please check the {count} errors when you can.",
|
|
250
|
+
"Still waiting! {count} errors need your attention.",
|
|
251
|
+
"Don't forget! There are {count} unresolved errors in your session."
|
|
252
|
+
],
|
|
253
|
+
errorReminderDelaySeconds: 20,
|
|
198
254
|
enableAIMessages: false,
|
|
199
255
|
aiEndpoint: 'http://localhost:11434/v1',
|
|
200
256
|
aiModel: 'llama3',
|
|
201
257
|
aiApiKey: '',
|
|
202
258
|
aiTimeout: 15000,
|
|
203
259
|
aiFallbackToStatic: true,
|
|
260
|
+
enableContextAwareAI: false,
|
|
204
261
|
aiPrompts: {
|
|
205
262
|
idle: "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.",
|
|
206
263
|
permission: "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.",
|
|
207
264
|
question: "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.",
|
|
265
|
+
error: "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.",
|
|
208
266
|
idleReminder: "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.",
|
|
209
267
|
permissionReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.",
|
|
210
|
-
questionReminder: "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes."
|
|
268
|
+
questionReminder: "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.",
|
|
269
|
+
errorReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes."
|
|
211
270
|
},
|
|
212
271
|
idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3',
|
|
213
272
|
permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
|
|
214
273
|
questionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
|
|
274
|
+
errorSound: 'assets/Machine-alert-beep-sound-effect.mp3',
|
|
215
275
|
wakeMonitor: true,
|
|
216
|
-
forceVolume:
|
|
276
|
+
forceVolume: false,
|
|
217
277
|
volumeThreshold: 50,
|
|
218
278
|
enableToast: true,
|
|
219
279
|
enableSound: true,
|
|
280
|
+
enableDesktopNotification: true,
|
|
281
|
+
desktopNotificationTimeout: 5,
|
|
282
|
+
showProjectInNotification: true,
|
|
283
|
+
suppressWhenFocused: true,
|
|
284
|
+
alwaysNotify: false,
|
|
285
|
+
enableWebhook: false,
|
|
286
|
+
webhookUrl: "",
|
|
287
|
+
webhookUsername: "OpenCode Notify",
|
|
288
|
+
webhookEvents: ["idle", "permission", "error", "question"],
|
|
289
|
+
webhookMentionOnPermission: false,
|
|
290
|
+
soundThemeDir: "",
|
|
291
|
+
randomizeSoundFromTheme: true,
|
|
292
|
+
perProjectSounds: false,
|
|
293
|
+
projectSoundSeed: 0,
|
|
220
294
|
idleThresholdSeconds: 60,
|
|
221
295
|
debugLog: false
|
|
222
296
|
});
|
|
@@ -229,7 +303,8 @@ const getDefaultConfigObject = () => ({
|
|
|
229
303
|
* @param {string} prefix
|
|
230
304
|
* @returns {string[]} Array of field paths that were added
|
|
231
305
|
*/
|
|
232
|
-
const findNewFields = (defaults, user, prefix = '') => {
|
|
306
|
+
export const findNewFields = (defaults, user, prefix = '') => {
|
|
307
|
+
|
|
233
308
|
const newFields = [];
|
|
234
309
|
|
|
235
310
|
if (typeof defaults !== 'object' || defaults === null || Array.isArray(defaults)) {
|
|
@@ -293,6 +368,25 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
293
368
|
// Set to false to disable all notifications without uninstalling.
|
|
294
369
|
"enabled": ${overrides.enabled !== undefined ? overrides.enabled : true},
|
|
295
370
|
|
|
371
|
+
// ============================================================
|
|
372
|
+
// GRANULAR NOTIFICATION CONTROL
|
|
373
|
+
// ============================================================
|
|
374
|
+
// Enable or disable notifications for specific event types.
|
|
375
|
+
// If disabled, no sound, TTS, desktop, or webhook notifications
|
|
376
|
+
// will be sent for that specific category.
|
|
377
|
+
"enableIdleNotification": ${overrides.enableIdleNotification !== undefined ? overrides.enableIdleNotification : true}, // Agent finished work
|
|
378
|
+
"enablePermissionNotification": ${overrides.enablePermissionNotification !== undefined ? overrides.enablePermissionNotification : true}, // Agent needs permission
|
|
379
|
+
"enableQuestionNotification": ${overrides.enableQuestionNotification !== undefined ? overrides.enableQuestionNotification : true}, // Agent asks a question
|
|
380
|
+
"enableErrorNotification": ${overrides.enableErrorNotification !== undefined ? overrides.enableErrorNotification : false}, // Agent encountered an error
|
|
381
|
+
|
|
382
|
+
// Enable or disable reminders for specific event types.
|
|
383
|
+
// If disabled, the initial notification will still fire, but no
|
|
384
|
+
// follow-up TTS reminders will be scheduled.
|
|
385
|
+
"enableIdleReminder": ${overrides.enableIdleReminder !== undefined ? overrides.enableIdleReminder : true},
|
|
386
|
+
"enablePermissionReminder": ${overrides.enablePermissionReminder !== undefined ? overrides.enablePermissionReminder : true},
|
|
387
|
+
"enableQuestionReminder": ${overrides.enableQuestionReminder !== undefined ? overrides.enableQuestionReminder : true},
|
|
388
|
+
"enableErrorReminder": ${overrides.enableErrorReminder !== undefined ? overrides.enableErrorReminder : false},
|
|
389
|
+
|
|
296
390
|
// ============================================================
|
|
297
391
|
// NOTIFICATION MODE SETTINGS (Smart Notification System)
|
|
298
392
|
// ============================================================
|
|
@@ -324,10 +418,10 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
324
418
|
// ============================================================
|
|
325
419
|
// TTS ENGINE SELECTION
|
|
326
420
|
// ============================================================
|
|
327
|
-
// 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI)
|
|
328
|
-
// 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
|
|
329
|
-
// 'edge' - Good quality neural voices (free, requires: pip install edge-tts)
|
|
330
|
-
// 'sapi' - Windows built-in voices (free, offline, robotic)
|
|
421
|
+
// 'openai' - OpenAI-compatible TTS (Self-hosted/Cloud, e.g. Kokoro, LocalAI)
|
|
422
|
+
// 'elevenlabs' - Best quality, anime-like voices (requires API key, free tier: 10k chars/month)
|
|
423
|
+
// 'edge' - Good quality neural voices (free, requires: pip install edge-tts)
|
|
424
|
+
// 'sapi' - Windows built-in voices (free, offline, robotic)
|
|
331
425
|
"ttsEngine": "${overrides.ttsEngine || 'elevenlabs'}",
|
|
332
426
|
|
|
333
427
|
// Enable TTS for notifications (falls back to sound files if TTS fails)
|
|
@@ -556,6 +650,51 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
556
650
|
// Question batch window (ms) - how long to wait for more questions before notifying
|
|
557
651
|
"questionBatchWindowMs": ${overrides.questionBatchWindowMs !== undefined ? overrides.questionBatchWindowMs : 800},
|
|
558
652
|
|
|
653
|
+
// ============================================================
|
|
654
|
+
// ERROR NOTIFICATION SETTINGS (Session Errors)
|
|
655
|
+
// ============================================================
|
|
656
|
+
// Notify users when the agent encounters an error during execution.
|
|
657
|
+
// Error notifications use more urgent messaging to get user attention.
|
|
658
|
+
|
|
659
|
+
// Messages when agent encounters an error
|
|
660
|
+
"errorTTSMessages": ${formatJSON(overrides.errorTTSMessages || [
|
|
661
|
+
"Oops! Something went wrong. Please check for errors.",
|
|
662
|
+
"Alert! The agent encountered an error and needs your attention.",
|
|
663
|
+
"Error detected! Please review the issue when you can.",
|
|
664
|
+
"Houston, we have a problem! An error occurred during the task.",
|
|
665
|
+
"Heads up! There was an error that requires your attention."
|
|
666
|
+
], 4)},
|
|
667
|
+
|
|
668
|
+
// Messages for MULTIPLE errors (use {count} placeholder)
|
|
669
|
+
"errorTTSMessagesMultiple": ${formatJSON(overrides.errorTTSMessagesMultiple || [
|
|
670
|
+
"Oops! There are {count} errors that need your attention.",
|
|
671
|
+
"Alert! The agent encountered {count} errors. Please review.",
|
|
672
|
+
"{count} errors detected! Please check when you can.",
|
|
673
|
+
"Houston, we have {count} problems! Multiple errors occurred.",
|
|
674
|
+
"Heads up! {count} errors require your attention."
|
|
675
|
+
], 4)},
|
|
676
|
+
|
|
677
|
+
// Reminder messages for errors (more urgent - used after delay)
|
|
678
|
+
"errorReminderTTSMessages": ${formatJSON(overrides.errorReminderTTSMessages || [
|
|
679
|
+
"Hey! There's still an error waiting for your attention.",
|
|
680
|
+
"Reminder: An error occurred and hasn't been addressed yet.",
|
|
681
|
+
"The agent is stuck! Please check the error when you can.",
|
|
682
|
+
"Still waiting! That error needs your attention.",
|
|
683
|
+
"Don't forget! There's an unresolved error in your session."
|
|
684
|
+
], 4)},
|
|
685
|
+
|
|
686
|
+
// Reminder messages for MULTIPLE errors (use {count} placeholder)
|
|
687
|
+
"errorReminderTTSMessagesMultiple": ${formatJSON(overrides.errorReminderTTSMessagesMultiple || [
|
|
688
|
+
"Hey! There are still {count} errors waiting for your attention.",
|
|
689
|
+
"Reminder: {count} errors occurred and haven't been addressed yet.",
|
|
690
|
+
"The agent is stuck! Please check the {count} errors when you can.",
|
|
691
|
+
"Still waiting! {count} errors need your attention.",
|
|
692
|
+
"Don't forget! There are {count} unresolved errors in your session."
|
|
693
|
+
], 4)},
|
|
694
|
+
|
|
695
|
+
// Delay (in seconds) before error reminder fires (shorter than idle for urgency)
|
|
696
|
+
"errorReminderDelaySeconds": ${overrides.errorReminderDelaySeconds !== undefined ? overrides.errorReminderDelaySeconds : 20},
|
|
697
|
+
|
|
559
698
|
// ============================================================
|
|
560
699
|
// AI MESSAGE GENERATION (OpenAI-Compatible Endpoints)
|
|
561
700
|
// ============================================================
|
|
@@ -592,6 +731,14 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
592
731
|
// Fallback to static preset messages if AI generation fails
|
|
593
732
|
"aiFallbackToStatic": ${overrides.aiFallbackToStatic !== undefined ? overrides.aiFallbackToStatic : true},
|
|
594
733
|
|
|
734
|
+
// Enable context-aware AI messages (includes project name, task title, and change summary)
|
|
735
|
+
// When enabled, AI-generated notifications will include relevant context like:
|
|
736
|
+
// - Project name (e.g., "Your work on MyProject is complete!")
|
|
737
|
+
// - Task/session title if available
|
|
738
|
+
// - Change summary (files modified, lines added/deleted)
|
|
739
|
+
// Disabled by default - enable this for more personalized notifications
|
|
740
|
+
"enableContextAwareAI": ${overrides.enableContextAwareAI !== undefined ? overrides.enableContextAwareAI : false},
|
|
741
|
+
|
|
595
742
|
// Custom prompts for each notification type
|
|
596
743
|
// The AI will generate a short message based on these prompts
|
|
597
744
|
// Keep prompts concise - they're sent with each notification
|
|
@@ -599,9 +746,11 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
599
746
|
"idle": "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.",
|
|
600
747
|
"permission": "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.",
|
|
601
748
|
"question": "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.",
|
|
749
|
+
"error": "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.",
|
|
602
750
|
"idleReminder": "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.",
|
|
603
751
|
"permissionReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.",
|
|
604
|
-
"questionReminder": "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes."
|
|
752
|
+
"questionReminder": "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.",
|
|
753
|
+
"errorReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes."
|
|
605
754
|
}, 4)},
|
|
606
755
|
|
|
607
756
|
// ============================================================
|
|
@@ -615,6 +764,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
615
764
|
"idleSound": "${overrides.idleSound || 'assets/Soft-high-tech-notification-sound-effect.mp3'}",
|
|
616
765
|
"permissionSound": "${overrides.permissionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
|
|
617
766
|
"questionSound": "${overrides.questionSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
|
|
767
|
+
"errorSound": "${overrides.errorSound || 'assets/Machine-alert-beep-sound-effect.mp3'}",
|
|
618
768
|
|
|
619
769
|
// ============================================================
|
|
620
770
|
// GENERAL SETTINGS
|
|
@@ -624,7 +774,7 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
624
774
|
"wakeMonitor": ${overrides.wakeMonitor !== undefined ? overrides.wakeMonitor : true},
|
|
625
775
|
|
|
626
776
|
// Force system volume up if below threshold
|
|
627
|
-
"forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume :
|
|
777
|
+
"forceVolume": ${overrides.forceVolume !== undefined ? overrides.forceVolume : false},
|
|
628
778
|
|
|
629
779
|
// Volume threshold (0-100): force volume if current level is below this
|
|
630
780
|
"volumeThreshold": ${overrides.volumeThreshold !== undefined ? overrides.volumeThreshold : 50},
|
|
@@ -635,6 +785,109 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
|
|
|
635
785
|
// Enable audio notifications (sound files and TTS)
|
|
636
786
|
"enableSound": ${overrides.enableSound !== undefined ? overrides.enableSound : true},
|
|
637
787
|
|
|
788
|
+
// ============================================================
|
|
789
|
+
// DESKTOP NOTIFICATION SETTINGS
|
|
790
|
+
// ============================================================
|
|
791
|
+
// Native desktop notifications (Windows Toast, macOS Notification Center, Linux notify-send)
|
|
792
|
+
// These appear as system notifications alongside sound and TTS.
|
|
793
|
+
//
|
|
794
|
+
// Note: On Linux, you may need to install libnotify-bin:
|
|
795
|
+
// Ubuntu/Debian: sudo apt install libnotify-bin
|
|
796
|
+
// Fedora: sudo dnf install libnotify
|
|
797
|
+
// Arch: sudo pacman -S libnotify
|
|
798
|
+
|
|
799
|
+
// Enable native desktop notifications
|
|
800
|
+
"enableDesktopNotification": ${overrides.enableDesktopNotification !== undefined ? overrides.enableDesktopNotification : true},
|
|
801
|
+
|
|
802
|
+
// How long the notification stays on screen (in seconds)
|
|
803
|
+
// Note: Some platforms may ignore this (especially Windows 10+)
|
|
804
|
+
"desktopNotificationTimeout": ${overrides.desktopNotificationTimeout !== undefined ? overrides.desktopNotificationTimeout : 5},
|
|
805
|
+
|
|
806
|
+
// Include the project name in notification titles for easier identification
|
|
807
|
+
// Example: "OpenCode - MyProject" instead of just "OpenCode"
|
|
808
|
+
"showProjectInNotification": ${overrides.showProjectInNotification !== undefined ? overrides.showProjectInNotification : true},
|
|
809
|
+
|
|
810
|
+
// ============================================================
|
|
811
|
+
// FOCUS DETECTION SETTINGS
|
|
812
|
+
// ============================================================
|
|
813
|
+
// Suppress notifications when you're actively looking at the terminal.
|
|
814
|
+
// This prevents notifications from interrupting you when you're already
|
|
815
|
+
// paying attention to the OpenCode terminal.
|
|
816
|
+
//
|
|
817
|
+
// PLATFORM SUPPORT:
|
|
818
|
+
// macOS: Full support - Uses AppleScript to detect frontmost application
|
|
819
|
+
// Windows: Not supported - No reliable API available
|
|
820
|
+
// Linux: Not supported - Varies by desktop environment
|
|
821
|
+
//
|
|
822
|
+
// When focus detection is not supported on your platform, notifications
|
|
823
|
+
// will always be sent (fail-open behavior).
|
|
824
|
+
|
|
825
|
+
// Suppress sound and desktop notifications when terminal is focused
|
|
826
|
+
// TTS reminders are still allowed (user might step away after task completes)
|
|
827
|
+
"suppressWhenFocused": ${overrides.suppressWhenFocused !== undefined ? overrides.suppressWhenFocused : true},
|
|
828
|
+
|
|
829
|
+
// Override focus detection: always send notifications even when terminal is focused
|
|
830
|
+
// Set to true to disable focus-based suppression entirely
|
|
831
|
+
"alwaysNotify": ${overrides.alwaysNotify !== undefined ? overrides.alwaysNotify : false},
|
|
832
|
+
|
|
833
|
+
// ============================================================
|
|
834
|
+
// WEBHOOK NOTIFICATION SETTINGS (Discord/Generic)
|
|
835
|
+
// ============================================================
|
|
836
|
+
// Send notifications to a Discord webhook or any compatible endpoint.
|
|
837
|
+
// This allows you to receive notifications on your phone or other devices.
|
|
838
|
+
|
|
839
|
+
// Enable webhook notifications
|
|
840
|
+
"enableWebhook": ${overrides.enableWebhook !== undefined ? overrides.enableWebhook : false},
|
|
841
|
+
|
|
842
|
+
// Webhook URL (e.g., https://discord.com/api/webhooks/...)
|
|
843
|
+
"webhookUrl": "${overrides.webhookUrl || ''}",
|
|
844
|
+
|
|
845
|
+
// Username to show in the webhook message
|
|
846
|
+
"webhookUsername": "${overrides.webhookUsername || 'OpenCode Notify'}",
|
|
847
|
+
|
|
848
|
+
// Events that should trigger a webhook notification
|
|
849
|
+
// Options: "idle", "permission", "error", "question"
|
|
850
|
+
"webhookEvents": ${formatJSON(overrides.webhookEvents || ["idle", "permission", "error", "question"], 4)},
|
|
851
|
+
|
|
852
|
+
// Mention @everyone on permission requests (Discord only)
|
|
853
|
+
"webhookMentionOnPermission": ${overrides.webhookMentionOnPermission !== undefined ? overrides.webhookMentionOnPermission : false},
|
|
854
|
+
|
|
855
|
+
// ============================================================
|
|
856
|
+
// SOUND THEME SETTINGS (Themed Sound Packs)
|
|
857
|
+
// ============================================================
|
|
858
|
+
// Configure a directory containing custom sound files for notifications.
|
|
859
|
+
// This allows you to use themed sound packs (e.g., Warcraft, StarCraft, etc.)
|
|
860
|
+
//
|
|
861
|
+
// Directory structure should contain:
|
|
862
|
+
// /path/to/theme/idle/ - Sounds for task completion
|
|
863
|
+
// /path/to/theme/permission/ - Sounds for permission requests
|
|
864
|
+
// /path/to/theme/error/ - Sounds for agent errors
|
|
865
|
+
// /path/to/theme/question/ - Sounds for agent questions
|
|
866
|
+
//
|
|
867
|
+
// If a specific event folder is missing, it falls back to default sounds.
|
|
868
|
+
|
|
869
|
+
// Path to your custom sound theme directory (absolute path recommended)
|
|
870
|
+
"soundThemeDir": "${overrides.soundThemeDir || ''}",
|
|
871
|
+
|
|
872
|
+
// Pick a random sound from the appropriate theme folder for each notification
|
|
873
|
+
"randomizeSoundFromTheme": ${overrides.randomizeSoundFromTheme !== undefined ? overrides.randomizeSoundFromTheme : true},
|
|
874
|
+
|
|
875
|
+
// ============================================================
|
|
876
|
+
// PER-PROJECT SOUND SETTINGS
|
|
877
|
+
// ============================================================
|
|
878
|
+
// Assign a unique notification sound to each project based on its path.
|
|
879
|
+
// This helps you distinguish which project is notifying you when working
|
|
880
|
+
// on multiple tasks simultaneously.
|
|
881
|
+
//
|
|
882
|
+
// Note: Requires sounds named 'ding1.mp3' through 'ding6.mp3' in your
|
|
883
|
+
// assets/ folder. If disabled, default sound files are used.
|
|
884
|
+
|
|
885
|
+
// Enable unique sounds per project
|
|
886
|
+
"perProjectSounds": ${overrides.perProjectSounds !== undefined ? overrides.perProjectSounds : false},
|
|
887
|
+
|
|
888
|
+
// Seed value to change sound assignments (0-999)
|
|
889
|
+
"projectSoundSeed": ${overrides.projectSoundSeed !== undefined ? overrides.projectSoundSeed : 0},
|
|
890
|
+
|
|
638
891
|
// Consider monitor asleep after this many seconds of inactivity (Windows only)
|
|
639
892
|
"idleThresholdSeconds": ${overrides.idleThresholdSeconds !== undefined ? overrides.idleThresholdSeconds : 60},
|
|
640
893
|
|
|
@@ -701,6 +954,10 @@ export const loadConfig = (name, defaults = {}) => {
|
|
|
701
954
|
const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, 'package.json'), 'utf-8'));
|
|
702
955
|
const currentVersion = pkg.version;
|
|
703
956
|
|
|
957
|
+
// Get default config object with current version early so it can be used for peeking
|
|
958
|
+
const defaultConfig = getDefaultConfigObject();
|
|
959
|
+
defaultConfig._configVersion = currentVersion;
|
|
960
|
+
|
|
704
961
|
// Always ensure bundled assets are present
|
|
705
962
|
copyBundledAssets(configDir);
|
|
706
963
|
|
|
@@ -711,28 +968,50 @@ export const loadConfig = (name, defaults = {}) => {
|
|
|
711
968
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
712
969
|
existingConfig = parseJSONC(content);
|
|
713
970
|
} catch (error) {
|
|
714
|
-
// If file is invalid JSONC, we'll
|
|
715
|
-
|
|
971
|
+
// If file is invalid JSONC, we'll use defaults for this run but NOT overwrite the user's file
|
|
972
|
+
// This prevents accidental loss of configuration due to a simple syntax error
|
|
973
|
+
debugLogToFile(`Warning: Config file at ${filePath} is invalid (${error.message}). Using default values for now. Please check your config for syntax errors.`, configDir);
|
|
974
|
+
existingConfig = null; // Forces CASE 1 logic but we'll modify it to avoid writing
|
|
975
|
+
|
|
976
|
+
// SMART PEEK: Even if parsing fails, try to see if "enabled" field is set to false/disabled
|
|
977
|
+
// to respect the user's intent to disable the plugin even with syntax errors.
|
|
978
|
+
try {
|
|
979
|
+
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
980
|
+
// Match both boolean and string values for "enabled"
|
|
981
|
+
const enabledMatch = rawContent.match(/"enabled"\s*:\s*(false|true|"disabled"|"enabled"|'disabled'|'enabled')/i);
|
|
982
|
+
if (enabledMatch) {
|
|
983
|
+
const val = enabledMatch[1].replace(/["']/g, '').toLowerCase();
|
|
984
|
+
const isActuallyEnabled = (val === 'true' || val === 'enabled');
|
|
985
|
+
|
|
986
|
+
// Inject into defaults and defaultConfig so it's picked up
|
|
987
|
+
defaults.enabled = isActuallyEnabled;
|
|
988
|
+
defaultConfig.enabled = isActuallyEnabled;
|
|
989
|
+
debugLogToFile(`Detected 'enabled: ${isActuallyEnabled}' via emergency regex peek (syntax error in file)`, configDir);
|
|
990
|
+
}
|
|
991
|
+
} catch (e) {
|
|
992
|
+
// Peek failed, just proceed with CASE 1
|
|
993
|
+
}
|
|
716
994
|
}
|
|
717
|
-
}
|
|
718
995
|
|
|
719
|
-
|
|
720
|
-
const defaultConfig = getDefaultConfigObject();
|
|
721
|
-
defaultConfig._configVersion = currentVersion;
|
|
996
|
+
}
|
|
722
997
|
|
|
723
|
-
// CASE 1: No existing config
|
|
998
|
+
// CASE 1: No existing config (missing or invalid)
|
|
724
999
|
if (!existingConfig) {
|
|
1000
|
+
|
|
725
1001
|
try {
|
|
726
1002
|
// Ensure config directory exists
|
|
727
1003
|
if (!fs.existsSync(configDir)) {
|
|
728
1004
|
fs.mkdirSync(configDir, { recursive: true });
|
|
729
1005
|
}
|
|
730
1006
|
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
fs.
|
|
734
|
-
|
|
735
|
-
|
|
1007
|
+
// ONLY write a fresh config file if it doesn't exist at all.
|
|
1008
|
+
// If it exists but was invalid, we already logged a warning and we'll just return defaults.
|
|
1009
|
+
if (!fs.existsSync(filePath)) {
|
|
1010
|
+
// Generate new config file with all documentation comments
|
|
1011
|
+
const newConfigContent = generateDefaultConfig({}, currentVersion);
|
|
1012
|
+
fs.writeFileSync(filePath, newConfigContent, 'utf-8');
|
|
1013
|
+
debugLogToFile(`Initialized default config at ${filePath}`, configDir);
|
|
1014
|
+
}
|
|
736
1015
|
|
|
737
1016
|
// Return the default config merged with any passed defaults
|
|
738
1017
|
return { ...defaults, ...defaultConfig };
|
|
@@ -742,6 +1021,7 @@ export const loadConfig = (name, defaults = {}) => {
|
|
|
742
1021
|
}
|
|
743
1022
|
}
|
|
744
1023
|
|
|
1024
|
+
|
|
745
1025
|
// CASE 2: Existing config - smart merge to add new fields only
|
|
746
1026
|
// Find what new fields need to be added (for logging)
|
|
747
1027
|
const newFields = findNewFields(defaultConfig, existingConfig);
|