opencode-smart-voice-notify 1.3.1 → 1.3.2
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 +413 -413
- package/example.config.jsonc +369 -369
- package/index.js +1430 -1387
- package/package.json +1 -1
- package/util/ai-messages.js +278 -278
- package/util/config.js +1058 -1058
- package/util/desktop-notify.js +319 -319
- package/util/focus-detect.js +372 -372
- package/util/per-project-sound.js +90 -90
- package/util/sound-theme.js +129 -129
- package/util/tts.js +620 -620
- package/util/webhook.js +743 -743
package/util/tts.js
CHANGED
|
@@ -1,342 +1,342 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
import { loadConfig } from './config.js';
|
|
5
|
-
import { createLinuxPlatform } from './linux.js';
|
|
6
|
-
|
|
7
|
-
const platform = os.platform();
|
|
8
|
-
// Remove module-level configDir constant that caches process.env prematurely
|
|
9
|
-
// const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Gets the current OpenCode config directory
|
|
13
|
-
* @returns {string}
|
|
14
|
-
*/
|
|
15
|
-
const getConfigDir = () => process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Loads the TTS configuration (shared with the notification plugin)
|
|
20
|
-
* @returns {object}
|
|
21
|
-
*/
|
|
22
|
-
export const getTTSConfig = () => {
|
|
23
|
-
return loadConfig('smart-voice-notify', {
|
|
24
|
-
ttsEngine: 'elevenlabs',
|
|
25
|
-
enableTTS: true,
|
|
26
|
-
elevenLabsApiKey: '',
|
|
27
|
-
elevenLabsVoiceId: 'cgSgspJ2msm6clMCkdW9',
|
|
28
|
-
elevenLabsModel: 'eleven_turbo_v2_5',
|
|
29
|
-
elevenLabsStability: 0.5,
|
|
30
|
-
elevenLabsSimilarity: 0.75,
|
|
31
|
-
elevenLabsStyle: 0.5,
|
|
32
|
-
edgeVoice: 'en-US-JennyNeural',
|
|
33
|
-
edgePitch: '+0Hz',
|
|
34
|
-
edgeRate: '+10%',
|
|
35
|
-
sapiVoice: 'Microsoft Zira Desktop',
|
|
36
|
-
sapiRate: -1,
|
|
37
|
-
sapiPitch: 'medium',
|
|
38
|
-
sapiVolume: 'loud',
|
|
39
|
-
|
|
40
|
-
// OpenAI-compatible TTS settings
|
|
41
|
-
openaiTtsEndpoint: '',
|
|
42
|
-
openaiTtsApiKey: '',
|
|
43
|
-
openaiTtsModel: 'tts-1',
|
|
44
|
-
openaiTtsVoice: 'alloy',
|
|
45
|
-
openaiTtsFormat: 'mp3',
|
|
46
|
-
openaiTtsSpeed: 1.0,
|
|
47
|
-
|
|
48
|
-
// ============================================================
|
|
49
|
-
// NOTIFICATION MODE & TTS REMINDER SETTINGS
|
|
50
|
-
// ============================================================
|
|
51
|
-
// 'sound-first' - Play sound immediately, TTS reminder after delay (default)
|
|
52
|
-
// 'tts-first' - Speak TTS immediately, no sound
|
|
53
|
-
// 'both' - Play sound AND speak TTS immediately
|
|
54
|
-
// 'sound-only' - Only play sound, no TTS at all
|
|
55
|
-
notificationMode: 'sound-first',
|
|
56
|
-
|
|
57
|
-
// Enable TTS reminder if user doesn't respond after sound notification
|
|
58
|
-
enableTTSReminder: true,
|
|
59
|
-
|
|
60
|
-
// Delay in seconds before TTS reminder (if user hasn't responded)
|
|
61
|
-
// Can be set globally or per-notification type
|
|
62
|
-
ttsReminderDelaySeconds: 30,
|
|
63
|
-
idleReminderDelaySeconds: 30,
|
|
64
|
-
permissionReminderDelaySeconds: 20,
|
|
65
|
-
|
|
66
|
-
// Follow-up reminders (if user still doesn't respond after first TTS)
|
|
67
|
-
enableFollowUpReminders: true,
|
|
68
|
-
maxFollowUpReminders: 3,
|
|
69
|
-
reminderBackoffMultiplier: 1.5, // Each follow-up waits longer (30s, 45s, 67.5s)
|
|
70
|
-
|
|
71
|
-
// ============================================================
|
|
72
|
-
// TTS MESSAGE VARIETY (Initial notifications - randomly selected)
|
|
73
|
-
// ============================================================
|
|
74
|
-
// Messages when agent finishes work
|
|
75
|
-
idleTTSMessages: [
|
|
76
|
-
'All done! Your task has been completed successfully.',
|
|
77
|
-
'Hey there! I finished working on your request.',
|
|
78
|
-
'Task complete! Ready for your review whenever you are.',
|
|
79
|
-
'Good news! Everything is done and ready for you.',
|
|
80
|
-
'Finished! Let me know if you need anything else.'
|
|
81
|
-
],
|
|
82
|
-
// Messages for permission requests
|
|
83
|
-
permissionTTSMessages: [
|
|
84
|
-
'Attention please! I need your permission to continue.',
|
|
85
|
-
'Hey! Quick approval needed to proceed with the task.',
|
|
86
|
-
'Heads up! There is a permission request waiting for you.',
|
|
87
|
-
'Excuse me! I need your authorization before I can continue.',
|
|
88
|
-
'Permission required! Please review and approve when ready.'
|
|
89
|
-
],
|
|
90
|
-
// Messages for MULTIPLE permission requests (use {count} placeholder)
|
|
91
|
-
permissionTTSMessagesMultiple: [
|
|
92
|
-
'Attention please! There are {count} permission requests waiting for your approval.',
|
|
93
|
-
'Hey! {count} permissions need your approval to continue.',
|
|
94
|
-
'Heads up! You have {count} pending permission requests.',
|
|
95
|
-
'Excuse me! I need your authorization for {count} different actions.',
|
|
96
|
-
'{count} permissions required! Please review and approve when ready.'
|
|
97
|
-
],
|
|
98
|
-
|
|
99
|
-
// ============================================================
|
|
100
|
-
// TTS REMINDER MESSAGES (More urgent/personalized - used after delay)
|
|
101
|
-
// ============================================================
|
|
102
|
-
// Reminder messages when agent finished but user hasn't responded
|
|
103
|
-
idleReminderTTSMessages: [
|
|
104
|
-
'Hey, are you still there? Your task has been waiting for review.',
|
|
105
|
-
'Just a gentle reminder - I finished your request a while ago!',
|
|
106
|
-
'Hello? I completed your task. Please take a look when you can.',
|
|
107
|
-
'Still waiting for you! The work is done and ready for review.',
|
|
108
|
-
'Knock knock! Your completed task is patiently waiting for you.'
|
|
109
|
-
],
|
|
110
|
-
// Reminder messages when permission still needed
|
|
111
|
-
permissionReminderTTSMessages: [
|
|
112
|
-
'Hey! I still need your permission to continue. Please respond!',
|
|
113
|
-
'Reminder: There is a pending permission request. I cannot proceed without you.',
|
|
114
|
-
'Hello? I am waiting for your approval. This is getting urgent!',
|
|
115
|
-
'Please check your screen! I really need your permission to move forward.',
|
|
116
|
-
'Still waiting for authorization! The task is on hold until you respond.'
|
|
117
|
-
],
|
|
118
|
-
// Reminder messages for MULTIPLE permissions (use {count} placeholder)
|
|
119
|
-
permissionReminderTTSMessagesMultiple: [
|
|
120
|
-
'Hey! I still need your approval for {count} permissions. Please respond!',
|
|
121
|
-
'Reminder: There are {count} pending permission requests. I cannot proceed without you.',
|
|
122
|
-
'Hello? I am waiting for your approval on {count} items. This is getting urgent!',
|
|
123
|
-
'Please check your screen! {count} permissions are waiting for your response.',
|
|
124
|
-
'Still waiting for authorization on {count} requests! The task is on hold.'
|
|
125
|
-
],
|
|
126
|
-
|
|
127
|
-
// Permission batch window (ms) - how long to wait for more permissions before notifying
|
|
128
|
-
permissionBatchWindowMs: 800,
|
|
129
|
-
|
|
130
|
-
// ============================================================
|
|
131
|
-
// QUESTION TOOL MESSAGES (SDK v1.1.7+ - Agent asking user questions)
|
|
132
|
-
// ============================================================
|
|
133
|
-
// Messages when agent asks user a question
|
|
134
|
-
questionTTSMessages: [
|
|
135
|
-
'Hey! I have a question for you. Please check your screen.',
|
|
136
|
-
'Attention! I need your input to continue.',
|
|
137
|
-
'Quick question! Please take a look when you have a moment.',
|
|
138
|
-
'I need some clarification. Could you please respond?',
|
|
139
|
-
'Question time! Your input is needed to proceed.'
|
|
140
|
-
],
|
|
141
|
-
// Messages for MULTIPLE questions (use {count} placeholder)
|
|
142
|
-
questionTTSMessagesMultiple: [
|
|
143
|
-
'Hey! I have {count} questions for you. Please check your screen.',
|
|
144
|
-
'Attention! I need your input on {count} items to continue.',
|
|
145
|
-
'{count} questions need your attention. Please take a look!',
|
|
146
|
-
'I need some clarifications. There are {count} questions waiting for you.',
|
|
147
|
-
'Question time! {count} questions need your response to proceed.'
|
|
148
|
-
],
|
|
149
|
-
// Reminder messages for questions
|
|
150
|
-
questionReminderTTSMessages: [
|
|
151
|
-
'Hey! I am still waiting for your answer. Please check the questions!',
|
|
152
|
-
'Reminder: There is a question waiting for your response.',
|
|
153
|
-
'Hello? I need your input to continue. Please respond when you can.',
|
|
154
|
-
'Still waiting for your answer! The task is on hold.',
|
|
155
|
-
'Your input is needed! Please check the pending question.'
|
|
156
|
-
],
|
|
157
|
-
// Reminder messages for MULTIPLE questions (use {count} placeholder)
|
|
158
|
-
questionReminderTTSMessagesMultiple: [
|
|
159
|
-
'Hey! I am still waiting for answers to {count} questions. Please respond!',
|
|
160
|
-
'Reminder: There are {count} questions waiting for your response.',
|
|
161
|
-
'Hello? I need your input on {count} items. Please respond when you can.',
|
|
162
|
-
'Still waiting for your answers on {count} questions! The task is on hold.',
|
|
163
|
-
'Your input is needed! {count} questions are pending your response.'
|
|
164
|
-
],
|
|
165
|
-
// Question reminder delay (seconds) - slightly less urgent than permissions
|
|
166
|
-
questionReminderDelaySeconds: 25,
|
|
167
|
-
// Question batch window (ms) - how long to wait for more questions before notifying
|
|
168
|
-
questionBatchWindowMs: 800,
|
|
169
|
-
|
|
170
|
-
// ============================================================
|
|
171
|
-
// SOUND FILES (Used for immediate notifications)
|
|
172
|
-
// ============================================================
|
|
173
|
-
idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3',
|
|
174
|
-
permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
|
|
175
|
-
questionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
|
|
176
|
-
|
|
177
|
-
// ============================================================
|
|
178
|
-
// GENERAL SETTINGS
|
|
179
|
-
// ============================================================
|
|
180
|
-
wakeMonitor: true,
|
|
181
|
-
forceVolume: true,
|
|
182
|
-
enableSound: true,
|
|
183
|
-
enableToast: true,
|
|
184
|
-
volumeThreshold: 50,
|
|
185
|
-
idleThresholdSeconds: 30,
|
|
186
|
-
debugLog: false
|
|
187
|
-
});
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
let elevenLabsQuotaExceeded = false;
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Creates a TTS utility instance
|
|
194
|
-
* @param {object} params - { $, client }
|
|
195
|
-
* @returns {object} TTS API
|
|
196
|
-
*/
|
|
197
|
-
export const createTTS = ({ $, client }) => {
|
|
198
|
-
const config = getTTSConfig();
|
|
199
|
-
const configDir = getConfigDir();
|
|
200
|
-
const logsDir = path.join(configDir, 'logs');
|
|
201
|
-
|
|
202
|
-
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
203
|
-
|
|
204
|
-
// Ensure logs directory exists if debug logging is enabled
|
|
205
|
-
if (config.debugLog && !fs.existsSync(logsDir)) {
|
|
206
|
-
try {
|
|
207
|
-
fs.mkdirSync(logsDir, { recursive: true });
|
|
208
|
-
} catch (e) {
|
|
209
|
-
// Silently fail - logging is optional
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Debug logging function (defined early so it can be passed to Linux platform)
|
|
214
|
-
const debugLog = (message) => {
|
|
215
|
-
if (!config.debugLog) return;
|
|
216
|
-
try {
|
|
217
|
-
const timestamp = new Date().toISOString();
|
|
218
|
-
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
219
|
-
} catch (e) {}
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
// Initialize Linux platform utilities (only used on Linux)
|
|
223
|
-
const linux = platform === 'linux' ? createLinuxPlatform({ $, debugLog }) : null;
|
|
224
|
-
|
|
225
|
-
const showToast = async (message, variant = 'info') => {
|
|
226
|
-
if (!config.enableToast) return;
|
|
227
|
-
try {
|
|
228
|
-
if (typeof client?.tui?.showToast === 'function') {
|
|
229
|
-
await client.tui.showToast({
|
|
230
|
-
body: {
|
|
231
|
-
message: message,
|
|
232
|
-
variant: variant,
|
|
233
|
-
duration: 6000
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
} catch (e) {}
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Play an audio file using system media player
|
|
242
|
-
*/
|
|
243
|
-
const playAudioFile = async (filePath, loops = 1) => {
|
|
244
|
-
if (!$) {
|
|
245
|
-
debugLog('playAudioFile: shell runner ($) not available');
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
try {
|
|
249
|
-
if (platform === 'win32') {
|
|
250
|
-
const cmd = `
|
|
251
|
-
Add-Type -AssemblyName presentationCore
|
|
252
|
-
$player = New-Object System.Windows.Media.MediaPlayer
|
|
253
|
-
$player.Volume = 1.0
|
|
254
|
-
for ($i = 0; $i -lt ${loops}; $i++) {
|
|
255
|
-
$player.Open([Uri]::new('${filePath.replace(/\\/g, '\\\\')}'))
|
|
256
|
-
$player.Play()
|
|
257
|
-
Start-Sleep -Milliseconds 500
|
|
258
|
-
while ($player.Position -lt $player.NaturalDuration.TimeSpan -and $player.HasAudio) {
|
|
259
|
-
Start-Sleep -Milliseconds 100
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
$player.Close()
|
|
263
|
-
`;
|
|
264
|
-
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
265
|
-
} else if (platform === 'darwin') {
|
|
266
|
-
for (let i = 0; i < loops; i++) {
|
|
267
|
-
await $`afplay ${filePath}`.quiet();
|
|
268
|
-
}
|
|
269
|
-
} else if (platform === 'linux' && linux) {
|
|
270
|
-
// Use the Linux platform module for audio playback
|
|
271
|
-
await linux.playAudioFile(filePath, loops);
|
|
272
|
-
} else {
|
|
273
|
-
// Generic fallback for other Unix-like systems
|
|
274
|
-
for (let i = 0; i < loops; i++) {
|
|
275
|
-
try {
|
|
276
|
-
await $`paplay ${filePath}`.quiet();
|
|
277
|
-
} catch {
|
|
278
|
-
await $`aplay ${filePath}`.quiet();
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
} catch (e) {
|
|
283
|
-
debugLog(`playAudioFile error: ${e.message}`);
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* ElevenLabs Engine (Online, High Quality, Anime-like voices)
|
|
289
|
-
*/
|
|
290
|
-
const speakWithElevenLabs = async (text) => {
|
|
291
|
-
if (elevenLabsQuotaExceeded) return false;
|
|
292
|
-
|
|
293
|
-
if (!config.elevenLabsApiKey) {
|
|
294
|
-
debugLog('speakWithElevenLabs: No API key configured');
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
const { ElevenLabsClient } = await import('@elevenlabs/elevenlabs-js');
|
|
300
|
-
const elClient = new ElevenLabsClient({ apiKey: config.elevenLabsApiKey });
|
|
301
|
-
|
|
302
|
-
const audio = await elClient.textToSpeech.convert(config.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9', {
|
|
303
|
-
text: text,
|
|
304
|
-
model_id: config.elevenLabsModel || 'eleven_turbo_v2_5',
|
|
305
|
-
voice_settings: {
|
|
306
|
-
stability: config.elevenLabsStability ?? 0.5,
|
|
307
|
-
similarity_boost: config.elevenLabsSimilarity ?? 0.75,
|
|
308
|
-
style: config.elevenLabsStyle ?? 0.5,
|
|
309
|
-
use_speaker_boost: true
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const tempFile = path.join(os.tmpdir(), `opencode-tts-${Date.now()}.mp3`);
|
|
314
|
-
const chunks = [];
|
|
315
|
-
for await (const chunk of audio) { chunks.push(chunk); }
|
|
316
|
-
fs.writeFileSync(tempFile, Buffer.concat(chunks));
|
|
317
|
-
|
|
318
|
-
await playAudioFile(tempFile);
|
|
319
|
-
try { fs.unlinkSync(tempFile); } catch (e) {}
|
|
320
|
-
return true;
|
|
321
|
-
} catch (e) {
|
|
322
|
-
debugLog(`speakWithElevenLabs error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
323
|
-
|
|
324
|
-
// Handle quota exceeded (401 specifically, or specific error message)
|
|
325
|
-
const isQuotaError =
|
|
326
|
-
e.statusCode === 401 ||
|
|
327
|
-
e.message?.includes('401') ||
|
|
328
|
-
e.message?.toLowerCase().includes('quota_exceeded') ||
|
|
329
|
-
e.message?.toLowerCase().includes('quota exceeded');
|
|
330
|
-
|
|
331
|
-
if (isQuotaError) {
|
|
332
|
-
elevenLabsQuotaExceeded = true;
|
|
333
|
-
await showToast("⚠️ ElevenLabs quota exceeded! Switching to Edge TTS for this session.", "error");
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return false;
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { createLinuxPlatform } from './linux.js';
|
|
6
|
+
|
|
7
|
+
const platform = os.platform();
|
|
8
|
+
// Remove module-level configDir constant that caches process.env prematurely
|
|
9
|
+
// const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Gets the current OpenCode config directory
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
const getConfigDir = () => process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Loads the TTS configuration (shared with the notification plugin)
|
|
20
|
+
* @returns {object}
|
|
21
|
+
*/
|
|
22
|
+
export const getTTSConfig = () => {
|
|
23
|
+
return loadConfig('smart-voice-notify', {
|
|
24
|
+
ttsEngine: 'elevenlabs',
|
|
25
|
+
enableTTS: true,
|
|
26
|
+
elevenLabsApiKey: '',
|
|
27
|
+
elevenLabsVoiceId: 'cgSgspJ2msm6clMCkdW9',
|
|
28
|
+
elevenLabsModel: 'eleven_turbo_v2_5',
|
|
29
|
+
elevenLabsStability: 0.5,
|
|
30
|
+
elevenLabsSimilarity: 0.75,
|
|
31
|
+
elevenLabsStyle: 0.5,
|
|
32
|
+
edgeVoice: 'en-US-JennyNeural',
|
|
33
|
+
edgePitch: '+0Hz',
|
|
34
|
+
edgeRate: '+10%',
|
|
35
|
+
sapiVoice: 'Microsoft Zira Desktop',
|
|
36
|
+
sapiRate: -1,
|
|
37
|
+
sapiPitch: 'medium',
|
|
38
|
+
sapiVolume: 'loud',
|
|
39
|
+
|
|
40
|
+
// OpenAI-compatible TTS settings
|
|
41
|
+
openaiTtsEndpoint: '',
|
|
42
|
+
openaiTtsApiKey: '',
|
|
43
|
+
openaiTtsModel: 'tts-1',
|
|
44
|
+
openaiTtsVoice: 'alloy',
|
|
45
|
+
openaiTtsFormat: 'mp3',
|
|
46
|
+
openaiTtsSpeed: 1.0,
|
|
47
|
+
|
|
48
|
+
// ============================================================
|
|
49
|
+
// NOTIFICATION MODE & TTS REMINDER SETTINGS
|
|
50
|
+
// ============================================================
|
|
51
|
+
// 'sound-first' - Play sound immediately, TTS reminder after delay (default)
|
|
52
|
+
// 'tts-first' - Speak TTS immediately, no sound
|
|
53
|
+
// 'both' - Play sound AND speak TTS immediately
|
|
54
|
+
// 'sound-only' - Only play sound, no TTS at all
|
|
55
|
+
notificationMode: 'sound-first',
|
|
56
|
+
|
|
57
|
+
// Enable TTS reminder if user doesn't respond after sound notification
|
|
58
|
+
enableTTSReminder: true,
|
|
59
|
+
|
|
60
|
+
// Delay in seconds before TTS reminder (if user hasn't responded)
|
|
61
|
+
// Can be set globally or per-notification type
|
|
62
|
+
ttsReminderDelaySeconds: 30,
|
|
63
|
+
idleReminderDelaySeconds: 30,
|
|
64
|
+
permissionReminderDelaySeconds: 20,
|
|
65
|
+
|
|
66
|
+
// Follow-up reminders (if user still doesn't respond after first TTS)
|
|
67
|
+
enableFollowUpReminders: true,
|
|
68
|
+
maxFollowUpReminders: 3,
|
|
69
|
+
reminderBackoffMultiplier: 1.5, // Each follow-up waits longer (30s, 45s, 67.5s)
|
|
70
|
+
|
|
71
|
+
// ============================================================
|
|
72
|
+
// TTS MESSAGE VARIETY (Initial notifications - randomly selected)
|
|
73
|
+
// ============================================================
|
|
74
|
+
// Messages when agent finishes work
|
|
75
|
+
idleTTSMessages: [
|
|
76
|
+
'All done! Your task has been completed successfully.',
|
|
77
|
+
'Hey there! I finished working on your request.',
|
|
78
|
+
'Task complete! Ready for your review whenever you are.',
|
|
79
|
+
'Good news! Everything is done and ready for you.',
|
|
80
|
+
'Finished! Let me know if you need anything else.'
|
|
81
|
+
],
|
|
82
|
+
// Messages for permission requests
|
|
83
|
+
permissionTTSMessages: [
|
|
84
|
+
'Attention please! I need your permission to continue.',
|
|
85
|
+
'Hey! Quick approval needed to proceed with the task.',
|
|
86
|
+
'Heads up! There is a permission request waiting for you.',
|
|
87
|
+
'Excuse me! I need your authorization before I can continue.',
|
|
88
|
+
'Permission required! Please review and approve when ready.'
|
|
89
|
+
],
|
|
90
|
+
// Messages for MULTIPLE permission requests (use {count} placeholder)
|
|
91
|
+
permissionTTSMessagesMultiple: [
|
|
92
|
+
'Attention please! There are {count} permission requests waiting for your approval.',
|
|
93
|
+
'Hey! {count} permissions need your approval to continue.',
|
|
94
|
+
'Heads up! You have {count} pending permission requests.',
|
|
95
|
+
'Excuse me! I need your authorization for {count} different actions.',
|
|
96
|
+
'{count} permissions required! Please review and approve when ready.'
|
|
97
|
+
],
|
|
98
|
+
|
|
99
|
+
// ============================================================
|
|
100
|
+
// TTS REMINDER MESSAGES (More urgent/personalized - used after delay)
|
|
101
|
+
// ============================================================
|
|
102
|
+
// Reminder messages when agent finished but user hasn't responded
|
|
103
|
+
idleReminderTTSMessages: [
|
|
104
|
+
'Hey, are you still there? Your task has been waiting for review.',
|
|
105
|
+
'Just a gentle reminder - I finished your request a while ago!',
|
|
106
|
+
'Hello? I completed your task. Please take a look when you can.',
|
|
107
|
+
'Still waiting for you! The work is done and ready for review.',
|
|
108
|
+
'Knock knock! Your completed task is patiently waiting for you.'
|
|
109
|
+
],
|
|
110
|
+
// Reminder messages when permission still needed
|
|
111
|
+
permissionReminderTTSMessages: [
|
|
112
|
+
'Hey! I still need your permission to continue. Please respond!',
|
|
113
|
+
'Reminder: There is a pending permission request. I cannot proceed without you.',
|
|
114
|
+
'Hello? I am waiting for your approval. This is getting urgent!',
|
|
115
|
+
'Please check your screen! I really need your permission to move forward.',
|
|
116
|
+
'Still waiting for authorization! The task is on hold until you respond.'
|
|
117
|
+
],
|
|
118
|
+
// Reminder messages for MULTIPLE permissions (use {count} placeholder)
|
|
119
|
+
permissionReminderTTSMessagesMultiple: [
|
|
120
|
+
'Hey! I still need your approval for {count} permissions. Please respond!',
|
|
121
|
+
'Reminder: There are {count} pending permission requests. I cannot proceed without you.',
|
|
122
|
+
'Hello? I am waiting for your approval on {count} items. This is getting urgent!',
|
|
123
|
+
'Please check your screen! {count} permissions are waiting for your response.',
|
|
124
|
+
'Still waiting for authorization on {count} requests! The task is on hold.'
|
|
125
|
+
],
|
|
126
|
+
|
|
127
|
+
// Permission batch window (ms) - how long to wait for more permissions before notifying
|
|
128
|
+
permissionBatchWindowMs: 800,
|
|
129
|
+
|
|
130
|
+
// ============================================================
|
|
131
|
+
// QUESTION TOOL MESSAGES (SDK v1.1.7+ - Agent asking user questions)
|
|
132
|
+
// ============================================================
|
|
133
|
+
// Messages when agent asks user a question
|
|
134
|
+
questionTTSMessages: [
|
|
135
|
+
'Hey! I have a question for you. Please check your screen.',
|
|
136
|
+
'Attention! I need your input to continue.',
|
|
137
|
+
'Quick question! Please take a look when you have a moment.',
|
|
138
|
+
'I need some clarification. Could you please respond?',
|
|
139
|
+
'Question time! Your input is needed to proceed.'
|
|
140
|
+
],
|
|
141
|
+
// Messages for MULTIPLE questions (use {count} placeholder)
|
|
142
|
+
questionTTSMessagesMultiple: [
|
|
143
|
+
'Hey! I have {count} questions for you. Please check your screen.',
|
|
144
|
+
'Attention! I need your input on {count} items to continue.',
|
|
145
|
+
'{count} questions need your attention. Please take a look!',
|
|
146
|
+
'I need some clarifications. There are {count} questions waiting for you.',
|
|
147
|
+
'Question time! {count} questions need your response to proceed.'
|
|
148
|
+
],
|
|
149
|
+
// Reminder messages for questions
|
|
150
|
+
questionReminderTTSMessages: [
|
|
151
|
+
'Hey! I am still waiting for your answer. Please check the questions!',
|
|
152
|
+
'Reminder: There is a question waiting for your response.',
|
|
153
|
+
'Hello? I need your input to continue. Please respond when you can.',
|
|
154
|
+
'Still waiting for your answer! The task is on hold.',
|
|
155
|
+
'Your input is needed! Please check the pending question.'
|
|
156
|
+
],
|
|
157
|
+
// Reminder messages for MULTIPLE questions (use {count} placeholder)
|
|
158
|
+
questionReminderTTSMessagesMultiple: [
|
|
159
|
+
'Hey! I am still waiting for answers to {count} questions. Please respond!',
|
|
160
|
+
'Reminder: There are {count} questions waiting for your response.',
|
|
161
|
+
'Hello? I need your input on {count} items. Please respond when you can.',
|
|
162
|
+
'Still waiting for your answers on {count} questions! The task is on hold.',
|
|
163
|
+
'Your input is needed! {count} questions are pending your response.'
|
|
164
|
+
],
|
|
165
|
+
// Question reminder delay (seconds) - slightly less urgent than permissions
|
|
166
|
+
questionReminderDelaySeconds: 25,
|
|
167
|
+
// Question batch window (ms) - how long to wait for more questions before notifying
|
|
168
|
+
questionBatchWindowMs: 800,
|
|
169
|
+
|
|
170
|
+
// ============================================================
|
|
171
|
+
// SOUND FILES (Used for immediate notifications)
|
|
172
|
+
// ============================================================
|
|
173
|
+
idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3',
|
|
174
|
+
permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
|
|
175
|
+
questionSound: 'assets/Machine-alert-beep-sound-effect.mp3',
|
|
176
|
+
|
|
177
|
+
// ============================================================
|
|
178
|
+
// GENERAL SETTINGS
|
|
179
|
+
// ============================================================
|
|
180
|
+
wakeMonitor: true,
|
|
181
|
+
forceVolume: true,
|
|
182
|
+
enableSound: true,
|
|
183
|
+
enableToast: true,
|
|
184
|
+
volumeThreshold: 50,
|
|
185
|
+
idleThresholdSeconds: 30,
|
|
186
|
+
debugLog: false
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
let elevenLabsQuotaExceeded = false;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Creates a TTS utility instance
|
|
194
|
+
* @param {object} params - { $, client }
|
|
195
|
+
* @returns {object} TTS API
|
|
196
|
+
*/
|
|
197
|
+
export const createTTS = ({ $, client }) => {
|
|
198
|
+
const config = getTTSConfig();
|
|
199
|
+
const configDir = getConfigDir();
|
|
200
|
+
const logsDir = path.join(configDir, 'logs');
|
|
201
|
+
|
|
202
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
203
|
+
|
|
204
|
+
// Ensure logs directory exists if debug logging is enabled
|
|
205
|
+
if (config.debugLog && !fs.existsSync(logsDir)) {
|
|
206
|
+
try {
|
|
207
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
// Silently fail - logging is optional
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Debug logging function (defined early so it can be passed to Linux platform)
|
|
214
|
+
const debugLog = (message) => {
|
|
215
|
+
if (!config.debugLog) return;
|
|
216
|
+
try {
|
|
217
|
+
const timestamp = new Date().toISOString();
|
|
218
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
219
|
+
} catch (e) {}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Initialize Linux platform utilities (only used on Linux)
|
|
223
|
+
const linux = platform === 'linux' ? createLinuxPlatform({ $, debugLog }) : null;
|
|
224
|
+
|
|
225
|
+
const showToast = async (message, variant = 'info') => {
|
|
226
|
+
if (!config.enableToast) return;
|
|
227
|
+
try {
|
|
228
|
+
if (typeof client?.tui?.showToast === 'function') {
|
|
229
|
+
await client.tui.showToast({
|
|
230
|
+
body: {
|
|
231
|
+
message: message,
|
|
232
|
+
variant: variant,
|
|
233
|
+
duration: 6000
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
} catch (e) {}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Play an audio file using system media player
|
|
242
|
+
*/
|
|
243
|
+
const playAudioFile = async (filePath, loops = 1) => {
|
|
244
|
+
if (!$) {
|
|
245
|
+
debugLog('playAudioFile: shell runner ($) not available');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
if (platform === 'win32') {
|
|
250
|
+
const cmd = `
|
|
251
|
+
Add-Type -AssemblyName presentationCore
|
|
252
|
+
$player = New-Object System.Windows.Media.MediaPlayer
|
|
253
|
+
$player.Volume = 1.0
|
|
254
|
+
for ($i = 0; $i -lt ${loops}; $i++) {
|
|
255
|
+
$player.Open([Uri]::new('${filePath.replace(/\\/g, '\\\\')}'))
|
|
256
|
+
$player.Play()
|
|
257
|
+
Start-Sleep -Milliseconds 500
|
|
258
|
+
while ($player.Position -lt $player.NaturalDuration.TimeSpan -and $player.HasAudio) {
|
|
259
|
+
Start-Sleep -Milliseconds 100
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
$player.Close()
|
|
263
|
+
`;
|
|
264
|
+
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
265
|
+
} else if (platform === 'darwin') {
|
|
266
|
+
for (let i = 0; i < loops; i++) {
|
|
267
|
+
await $`afplay ${filePath}`.quiet();
|
|
268
|
+
}
|
|
269
|
+
} else if (platform === 'linux' && linux) {
|
|
270
|
+
// Use the Linux platform module for audio playback
|
|
271
|
+
await linux.playAudioFile(filePath, loops);
|
|
272
|
+
} else {
|
|
273
|
+
// Generic fallback for other Unix-like systems
|
|
274
|
+
for (let i = 0; i < loops; i++) {
|
|
275
|
+
try {
|
|
276
|
+
await $`paplay ${filePath}`.quiet();
|
|
277
|
+
} catch {
|
|
278
|
+
await $`aplay ${filePath}`.quiet();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {
|
|
283
|
+
debugLog(`playAudioFile error: ${e.message}`);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* ElevenLabs Engine (Online, High Quality, Anime-like voices)
|
|
289
|
+
*/
|
|
290
|
+
const speakWithElevenLabs = async (text) => {
|
|
291
|
+
if (elevenLabsQuotaExceeded) return false;
|
|
292
|
+
|
|
293
|
+
if (!config.elevenLabsApiKey) {
|
|
294
|
+
debugLog('speakWithElevenLabs: No API key configured');
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const { ElevenLabsClient } = await import('@elevenlabs/elevenlabs-js');
|
|
300
|
+
const elClient = new ElevenLabsClient({ apiKey: config.elevenLabsApiKey });
|
|
301
|
+
|
|
302
|
+
const audio = await elClient.textToSpeech.convert(config.elevenLabsVoiceId || 'cgSgspJ2msm6clMCkdW9', {
|
|
303
|
+
text: text,
|
|
304
|
+
model_id: config.elevenLabsModel || 'eleven_turbo_v2_5',
|
|
305
|
+
voice_settings: {
|
|
306
|
+
stability: config.elevenLabsStability ?? 0.5,
|
|
307
|
+
similarity_boost: config.elevenLabsSimilarity ?? 0.75,
|
|
308
|
+
style: config.elevenLabsStyle ?? 0.5,
|
|
309
|
+
use_speaker_boost: true
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const tempFile = path.join(os.tmpdir(), `opencode-tts-${Date.now()}.mp3`);
|
|
314
|
+
const chunks = [];
|
|
315
|
+
for await (const chunk of audio) { chunks.push(chunk); }
|
|
316
|
+
fs.writeFileSync(tempFile, Buffer.concat(chunks));
|
|
317
|
+
|
|
318
|
+
await playAudioFile(tempFile);
|
|
319
|
+
try { fs.unlinkSync(tempFile); } catch (e) {}
|
|
320
|
+
return true;
|
|
321
|
+
} catch (e) {
|
|
322
|
+
debugLog(`speakWithElevenLabs error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
323
|
+
|
|
324
|
+
// Handle quota exceeded (401 specifically, or specific error message)
|
|
325
|
+
const isQuotaError =
|
|
326
|
+
e.statusCode === 401 ||
|
|
327
|
+
e.message?.includes('401') ||
|
|
328
|
+
e.message?.toLowerCase().includes('quota_exceeded') ||
|
|
329
|
+
e.message?.toLowerCase().includes('quota exceeded');
|
|
330
|
+
|
|
331
|
+
if (isQuotaError) {
|
|
332
|
+
elevenLabsQuotaExceeded = true;
|
|
333
|
+
await showToast("⚠️ ElevenLabs quota exceeded! Switching to Edge TTS for this session.", "error");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
340
|
/**
|
|
341
341
|
* Edge TTS Engine via Python CLI (Free, Neural voices)
|
|
342
342
|
* Uses Python edge-tts package via command line as it's more reliable than Node.js WebSocket libraries.
|
|
@@ -388,273 +388,273 @@ export const createTTS = ({ $, client }) => {
|
|
|
388
388
|
return false;
|
|
389
389
|
}
|
|
390
390
|
};
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Windows SAPI Engine (Offline, Built-in)
|
|
394
|
-
*/
|
|
395
|
-
const speakWithSAPI = async (text) => {
|
|
396
|
-
if (platform !== 'win32') {
|
|
397
|
-
debugLog('speakWithSAPI: skipped (not Windows)');
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
|
-
if (!$) {
|
|
401
|
-
debugLog('speakWithSAPI: skipped (shell helper $ not available)');
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
404
|
-
const scriptPath = path.join(os.tmpdir(), `opencode-sapi-${Date.now()}.ps1`);
|
|
405
|
-
try {
|
|
406
|
-
const escapedText = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
407
|
-
const voice = config.sapiVoice || 'Microsoft Zira Desktop';
|
|
408
|
-
const rate = Math.max(-10, Math.min(10, config.sapiRate || -1));
|
|
409
|
-
const pitch = config.sapiPitch || 'medium';
|
|
410
|
-
const volume = config.sapiVolume || 'loud';
|
|
411
|
-
const ratePercent = rate >= 0 ? `+${rate * 10}%` : `${rate * 5}%`;
|
|
412
|
-
|
|
413
|
-
const ssml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
414
|
-
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">
|
|
415
|
-
<voice name="${voice.replace(/"/g, '"')}">
|
|
416
|
-
<prosody rate="${ratePercent}" pitch="${pitch}" volume="${volume}">
|
|
417
|
-
${escapedText}
|
|
418
|
-
</prosody>
|
|
419
|
-
</voice>
|
|
420
|
-
</speak>`;
|
|
421
|
-
|
|
422
|
-
const scriptContent = `
|
|
423
|
-
Add-Type -AssemblyName System.Speech
|
|
424
|
-
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
425
|
-
try {
|
|
426
|
-
$synth.Rate = ${rate}
|
|
427
|
-
try { $synth.SelectVoice("${voice.replace(/"/g, '""')}") } catch { }
|
|
428
|
-
$ssml = @"
|
|
429
|
-
${ssml}
|
|
430
|
-
"@
|
|
431
|
-
$synth.SpeakSsml($ssml)
|
|
432
|
-
} catch {
|
|
433
|
-
[Console]::Error.WriteLine($_.Exception.Message)
|
|
434
|
-
exit 1
|
|
435
|
-
} finally {
|
|
436
|
-
if ($synth) { $synth.Dispose() }
|
|
437
|
-
}
|
|
438
|
-
`;
|
|
439
|
-
fs.writeFileSync(scriptPath, scriptContent, 'utf-8');
|
|
440
|
-
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${scriptPath}`.nothrow().quiet();
|
|
441
|
-
|
|
442
|
-
if (result.exitCode !== 0) {
|
|
443
|
-
debugLog(`speakWithSAPI failed with code ${result.exitCode}: ${result.stderr}`);
|
|
444
|
-
return false;
|
|
445
|
-
}
|
|
446
|
-
return true;
|
|
447
|
-
} catch (e) {
|
|
448
|
-
debugLog(`speakWithSAPI error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
449
|
-
return false;
|
|
450
|
-
} finally {
|
|
451
|
-
try { if (fs.existsSync(scriptPath)) fs.unlinkSync(scriptPath); } catch (e) {}
|
|
452
|
-
}
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* macOS Say Engine
|
|
457
|
-
*/
|
|
458
|
-
const speakWithSay = async (text) => {
|
|
459
|
-
if (platform !== 'darwin' || !$) return false;
|
|
460
|
-
try {
|
|
461
|
-
await $`say ${text}`.quiet();
|
|
462
|
-
return true;
|
|
463
|
-
} catch (e) {
|
|
464
|
-
debugLog(`speakWithSay error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
465
|
-
return false;
|
|
466
|
-
}
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* OpenAI-Compatible TTS Engine (Kokoro, OpenAI, LocalAI, etc.)
|
|
471
|
-
* Calls /v1/audio/speech endpoint with configurable base URL
|
|
472
|
-
*/
|
|
473
|
-
const speakWithOpenAI = async (text) => {
|
|
474
|
-
if (!config.openaiTtsEndpoint) {
|
|
475
|
-
debugLog('speakWithOpenAI: No endpoint configured');
|
|
476
|
-
return false;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
try {
|
|
480
|
-
const endpoint = config.openaiTtsEndpoint.replace(/\/$/, '');
|
|
481
|
-
const url = `${endpoint}/v1/audio/speech`;
|
|
482
|
-
|
|
483
|
-
const headers = {
|
|
484
|
-
'Content-Type': 'application/json',
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
// Add auth header if API key is provided
|
|
488
|
-
if (config.openaiTtsApiKey) {
|
|
489
|
-
headers['Authorization'] = `Bearer ${config.openaiTtsApiKey}`;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const body = {
|
|
493
|
-
model: config.openaiTtsModel || 'tts-1',
|
|
494
|
-
input: text,
|
|
495
|
-
voice: config.openaiTtsVoice || 'alloy',
|
|
496
|
-
response_format: config.openaiTtsFormat || 'mp3',
|
|
497
|
-
speed: config.openaiTtsSpeed ?? 1.0,
|
|
498
|
-
};
|
|
499
|
-
|
|
500
|
-
debugLog(`speakWithOpenAI: Calling ${url} with voice=${body.voice}, model=${body.model}`);
|
|
501
|
-
|
|
502
|
-
const response = await fetch(url, {
|
|
503
|
-
method: 'POST',
|
|
504
|
-
headers,
|
|
505
|
-
body: JSON.stringify(body),
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
if (!response.ok) {
|
|
509
|
-
const errorText = await response.text();
|
|
510
|
-
debugLog(`speakWithOpenAI: API error ${response.status}: ${errorText}`);
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const audioBuffer = await response.arrayBuffer();
|
|
515
|
-
const tempFile = path.join(os.tmpdir(), `opencode-tts-openai-${Date.now()}.mp3`);
|
|
516
|
-
fs.writeFileSync(tempFile, Buffer.from(audioBuffer));
|
|
517
|
-
|
|
518
|
-
await playAudioFile(tempFile);
|
|
519
|
-
try { fs.unlinkSync(tempFile); } catch (e) {}
|
|
520
|
-
return true;
|
|
521
|
-
} catch (e) {
|
|
522
|
-
debugLog(`speakWithOpenAI error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
523
|
-
return false;
|
|
524
|
-
}
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Get the current system idle time in seconds.
|
|
529
|
-
*/
|
|
530
|
-
const getSystemIdleSeconds = async () => {
|
|
531
|
-
if (platform === 'linux') {
|
|
532
|
-
// On Linux, we can't reliably detect idle time across all DEs
|
|
533
|
-
// Return a high value to always attempt wake (it's a no-op if already awake)
|
|
534
|
-
return 999;
|
|
535
|
-
}
|
|
536
|
-
if (platform !== 'win32' || !$) return 999;
|
|
537
|
-
try {
|
|
538
|
-
const cmd = `
|
|
539
|
-
Add-Type -TypeDefinition @'
|
|
540
|
-
using System;
|
|
541
|
-
using System.Runtime.InteropServices;
|
|
542
|
-
public static class IdleCheck {
|
|
543
|
-
[StructLayout(LayoutKind.Sequential)]
|
|
544
|
-
public struct LASTINPUTINFO {
|
|
545
|
-
public uint cbSize;
|
|
546
|
-
public uint dwTime;
|
|
547
|
-
}
|
|
548
|
-
[DllImport("user32.dll")]
|
|
549
|
-
public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
|
550
|
-
public static uint GetIdleSeconds() {
|
|
551
|
-
LASTINPUTINFO lii = new LASTINPUTINFO();
|
|
552
|
-
lii.cbSize = (uint)Marshal.SizeOf(lii);
|
|
553
|
-
if (GetLastInputInfo(ref lii)) {
|
|
554
|
-
return (uint)((Environment.TickCount - lii.dwTime) / 1000);
|
|
555
|
-
}
|
|
556
|
-
return 0;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
'@
|
|
560
|
-
[IdleCheck]::GetIdleSeconds()
|
|
561
|
-
`;
|
|
562
|
-
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
563
|
-
return parseInt(result.stdout?.toString().trim() || '0', 10);
|
|
564
|
-
} catch (e) {
|
|
565
|
-
return 999; // Assume idle on error
|
|
566
|
-
}
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Get the current system volume level (0-100).
|
|
571
|
-
*/
|
|
572
|
-
const getCurrentVolume = async () => {
|
|
573
|
-
// Use Linux platform module
|
|
574
|
-
if (platform === 'linux' && linux) {
|
|
575
|
-
return await linux.getCurrentVolume();
|
|
576
|
-
}
|
|
577
|
-
if (platform !== 'win32' || !$) return -1;
|
|
578
|
-
try {
|
|
579
|
-
const cmd = `
|
|
580
|
-
$signature = @'
|
|
581
|
-
[DllImport("winmm.dll")]
|
|
582
|
-
public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
|
|
583
|
-
'@
|
|
584
|
-
Add-Type -MemberDefinition $signature -Name Win32VolCheck -Namespace Win32 -PassThru | Out-Null
|
|
585
|
-
$vol = 0
|
|
586
|
-
$result = [Win32.Win32VolCheck]::waveOutGetVolume([IntPtr]::Zero, [ref]$vol)
|
|
587
|
-
if ($result -eq 0) {
|
|
588
|
-
$leftVol = $vol -band 0xFFFF
|
|
589
|
-
[Math]::Round(($leftVol / 65535) * 100)
|
|
590
|
-
} else { -1 }
|
|
591
|
-
`;
|
|
592
|
-
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
593
|
-
return parseInt(result.stdout?.toString().trim() || '-1', 10);
|
|
594
|
-
} catch (e) {
|
|
595
|
-
return -1;
|
|
596
|
-
}
|
|
597
|
-
};
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Wake Monitor Utility
|
|
601
|
-
*/
|
|
602
|
-
const wakeMonitor = async (force = false) => {
|
|
603
|
-
if (!config.wakeMonitor || !$) return;
|
|
604
|
-
try {
|
|
605
|
-
const idleSeconds = await getSystemIdleSeconds();
|
|
606
|
-
const threshold = config.idleThresholdSeconds || 30;
|
|
607
|
-
|
|
608
|
-
if (!force && idleSeconds < threshold) {
|
|
609
|
-
debugLog(`wakeMonitor: skipped (idle ${idleSeconds}s < ${threshold}s)`);
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
debugLog(`wakeMonitor: attempting to wake monitor (idle: ${idleSeconds}s, force: ${force})`);
|
|
614
|
-
|
|
615
|
-
if (platform === 'win32') {
|
|
616
|
-
const cmd = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('{F15}')`;
|
|
617
|
-
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
618
|
-
debugLog('wakeMonitor: Windows wake command executed');
|
|
619
|
-
} else if (platform === 'darwin') {
|
|
620
|
-
await $`caffeinate -u -t 1`.quiet();
|
|
621
|
-
debugLog('wakeMonitor: macOS wake command executed');
|
|
622
|
-
} else if (platform === 'linux' && linux) {
|
|
623
|
-
// Use the Linux platform module for wake monitor
|
|
624
|
-
await linux.wakeMonitor();
|
|
625
|
-
debugLog('wakeMonitor: Linux wake command executed');
|
|
626
|
-
}
|
|
627
|
-
} catch (e) {
|
|
628
|
-
debugLog(`wakeMonitor error: ${e.message}`);
|
|
629
|
-
}
|
|
630
|
-
};
|
|
631
|
-
|
|
632
|
-
/**
|
|
633
|
-
* Force Volume Utility
|
|
634
|
-
*/
|
|
635
|
-
const forceVolume = async (force = false) => {
|
|
636
|
-
if (!config.forceVolume || !$) return;
|
|
637
|
-
try {
|
|
638
|
-
if (!force) {
|
|
639
|
-
const currentVolume = await getCurrentVolume();
|
|
640
|
-
const volumeThreshold = config.volumeThreshold || 50;
|
|
641
|
-
if (currentVolume >= 0 && currentVolume >= volumeThreshold) return;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
if (platform === 'win32') {
|
|
645
|
-
const cmd = `$wsh = New-Object -ComObject WScript.Shell; 1..50 | ForEach-Object { $wsh.SendKeys([char]175) }`;
|
|
646
|
-
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
647
|
-
} else if (platform === 'darwin') {
|
|
648
|
-
await $`osascript -e "set volume output volume 100"`.quiet();
|
|
649
|
-
} else if (platform === 'linux' && linux) {
|
|
650
|
-
// Use the Linux platform module for force volume
|
|
651
|
-
await linux.forceVolume();
|
|
652
|
-
}
|
|
653
|
-
} catch (e) {
|
|
654
|
-
debugLog(`forceVolume error: ${e.message}`);
|
|
655
|
-
}
|
|
656
|
-
};
|
|
657
|
-
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Windows SAPI Engine (Offline, Built-in)
|
|
394
|
+
*/
|
|
395
|
+
const speakWithSAPI = async (text) => {
|
|
396
|
+
if (platform !== 'win32') {
|
|
397
|
+
debugLog('speakWithSAPI: skipped (not Windows)');
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
if (!$) {
|
|
401
|
+
debugLog('speakWithSAPI: skipped (shell helper $ not available)');
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
const scriptPath = path.join(os.tmpdir(), `opencode-sapi-${Date.now()}.ps1`);
|
|
405
|
+
try {
|
|
406
|
+
const escapedText = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
407
|
+
const voice = config.sapiVoice || 'Microsoft Zira Desktop';
|
|
408
|
+
const rate = Math.max(-10, Math.min(10, config.sapiRate || -1));
|
|
409
|
+
const pitch = config.sapiPitch || 'medium';
|
|
410
|
+
const volume = config.sapiVolume || 'loud';
|
|
411
|
+
const ratePercent = rate >= 0 ? `+${rate * 10}%` : `${rate * 5}%`;
|
|
412
|
+
|
|
413
|
+
const ssml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
414
|
+
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">
|
|
415
|
+
<voice name="${voice.replace(/"/g, '"')}">
|
|
416
|
+
<prosody rate="${ratePercent}" pitch="${pitch}" volume="${volume}">
|
|
417
|
+
${escapedText}
|
|
418
|
+
</prosody>
|
|
419
|
+
</voice>
|
|
420
|
+
</speak>`;
|
|
421
|
+
|
|
422
|
+
const scriptContent = `
|
|
423
|
+
Add-Type -AssemblyName System.Speech
|
|
424
|
+
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
425
|
+
try {
|
|
426
|
+
$synth.Rate = ${rate}
|
|
427
|
+
try { $synth.SelectVoice("${voice.replace(/"/g, '""')}") } catch { }
|
|
428
|
+
$ssml = @"
|
|
429
|
+
${ssml}
|
|
430
|
+
"@
|
|
431
|
+
$synth.SpeakSsml($ssml)
|
|
432
|
+
} catch {
|
|
433
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
434
|
+
exit 1
|
|
435
|
+
} finally {
|
|
436
|
+
if ($synth) { $synth.Dispose() }
|
|
437
|
+
}
|
|
438
|
+
`;
|
|
439
|
+
fs.writeFileSync(scriptPath, scriptContent, 'utf-8');
|
|
440
|
+
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${scriptPath}`.nothrow().quiet();
|
|
441
|
+
|
|
442
|
+
if (result.exitCode !== 0) {
|
|
443
|
+
debugLog(`speakWithSAPI failed with code ${result.exitCode}: ${result.stderr}`);
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
} catch (e) {
|
|
448
|
+
debugLog(`speakWithSAPI error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
449
|
+
return false;
|
|
450
|
+
} finally {
|
|
451
|
+
try { if (fs.existsSync(scriptPath)) fs.unlinkSync(scriptPath); } catch (e) {}
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* macOS Say Engine
|
|
457
|
+
*/
|
|
458
|
+
const speakWithSay = async (text) => {
|
|
459
|
+
if (platform !== 'darwin' || !$) return false;
|
|
460
|
+
try {
|
|
461
|
+
await $`say ${text}`.quiet();
|
|
462
|
+
return true;
|
|
463
|
+
} catch (e) {
|
|
464
|
+
debugLog(`speakWithSay error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* OpenAI-Compatible TTS Engine (Kokoro, OpenAI, LocalAI, etc.)
|
|
471
|
+
* Calls /v1/audio/speech endpoint with configurable base URL
|
|
472
|
+
*/
|
|
473
|
+
const speakWithOpenAI = async (text) => {
|
|
474
|
+
if (!config.openaiTtsEndpoint) {
|
|
475
|
+
debugLog('speakWithOpenAI: No endpoint configured');
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
const endpoint = config.openaiTtsEndpoint.replace(/\/$/, '');
|
|
481
|
+
const url = `${endpoint}/v1/audio/speech`;
|
|
482
|
+
|
|
483
|
+
const headers = {
|
|
484
|
+
'Content-Type': 'application/json',
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// Add auth header if API key is provided
|
|
488
|
+
if (config.openaiTtsApiKey) {
|
|
489
|
+
headers['Authorization'] = `Bearer ${config.openaiTtsApiKey}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const body = {
|
|
493
|
+
model: config.openaiTtsModel || 'tts-1',
|
|
494
|
+
input: text,
|
|
495
|
+
voice: config.openaiTtsVoice || 'alloy',
|
|
496
|
+
response_format: config.openaiTtsFormat || 'mp3',
|
|
497
|
+
speed: config.openaiTtsSpeed ?? 1.0,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
debugLog(`speakWithOpenAI: Calling ${url} with voice=${body.voice}, model=${body.model}`);
|
|
501
|
+
|
|
502
|
+
const response = await fetch(url, {
|
|
503
|
+
method: 'POST',
|
|
504
|
+
headers,
|
|
505
|
+
body: JSON.stringify(body),
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
if (!response.ok) {
|
|
509
|
+
const errorText = await response.text();
|
|
510
|
+
debugLog(`speakWithOpenAI: API error ${response.status}: ${errorText}`);
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const audioBuffer = await response.arrayBuffer();
|
|
515
|
+
const tempFile = path.join(os.tmpdir(), `opencode-tts-openai-${Date.now()}.mp3`);
|
|
516
|
+
fs.writeFileSync(tempFile, Buffer.from(audioBuffer));
|
|
517
|
+
|
|
518
|
+
await playAudioFile(tempFile);
|
|
519
|
+
try { fs.unlinkSync(tempFile); } catch (e) {}
|
|
520
|
+
return true;
|
|
521
|
+
} catch (e) {
|
|
522
|
+
debugLog(`speakWithOpenAI error: ${e?.message || String(e) || 'Unknown error'}`);
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Get the current system idle time in seconds.
|
|
529
|
+
*/
|
|
530
|
+
const getSystemIdleSeconds = async () => {
|
|
531
|
+
if (platform === 'linux') {
|
|
532
|
+
// On Linux, we can't reliably detect idle time across all DEs
|
|
533
|
+
// Return a high value to always attempt wake (it's a no-op if already awake)
|
|
534
|
+
return 999;
|
|
535
|
+
}
|
|
536
|
+
if (platform !== 'win32' || !$) return 999;
|
|
537
|
+
try {
|
|
538
|
+
const cmd = `
|
|
539
|
+
Add-Type -TypeDefinition @'
|
|
540
|
+
using System;
|
|
541
|
+
using System.Runtime.InteropServices;
|
|
542
|
+
public static class IdleCheck {
|
|
543
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
544
|
+
public struct LASTINPUTINFO {
|
|
545
|
+
public uint cbSize;
|
|
546
|
+
public uint dwTime;
|
|
547
|
+
}
|
|
548
|
+
[DllImport("user32.dll")]
|
|
549
|
+
public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
|
550
|
+
public static uint GetIdleSeconds() {
|
|
551
|
+
LASTINPUTINFO lii = new LASTINPUTINFO();
|
|
552
|
+
lii.cbSize = (uint)Marshal.SizeOf(lii);
|
|
553
|
+
if (GetLastInputInfo(ref lii)) {
|
|
554
|
+
return (uint)((Environment.TickCount - lii.dwTime) / 1000);
|
|
555
|
+
}
|
|
556
|
+
return 0;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
'@
|
|
560
|
+
[IdleCheck]::GetIdleSeconds()
|
|
561
|
+
`;
|
|
562
|
+
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
563
|
+
return parseInt(result.stdout?.toString().trim() || '0', 10);
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return 999; // Assume idle on error
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Get the current system volume level (0-100).
|
|
571
|
+
*/
|
|
572
|
+
const getCurrentVolume = async () => {
|
|
573
|
+
// Use Linux platform module
|
|
574
|
+
if (platform === 'linux' && linux) {
|
|
575
|
+
return await linux.getCurrentVolume();
|
|
576
|
+
}
|
|
577
|
+
if (platform !== 'win32' || !$) return -1;
|
|
578
|
+
try {
|
|
579
|
+
const cmd = `
|
|
580
|
+
$signature = @'
|
|
581
|
+
[DllImport("winmm.dll")]
|
|
582
|
+
public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
|
|
583
|
+
'@
|
|
584
|
+
Add-Type -MemberDefinition $signature -Name Win32VolCheck -Namespace Win32 -PassThru | Out-Null
|
|
585
|
+
$vol = 0
|
|
586
|
+
$result = [Win32.Win32VolCheck]::waveOutGetVolume([IntPtr]::Zero, [ref]$vol)
|
|
587
|
+
if ($result -eq 0) {
|
|
588
|
+
$leftVol = $vol -band 0xFFFF
|
|
589
|
+
[Math]::Round(($leftVol / 65535) * 100)
|
|
590
|
+
} else { -1 }
|
|
591
|
+
`;
|
|
592
|
+
const result = await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
593
|
+
return parseInt(result.stdout?.toString().trim() || '-1', 10);
|
|
594
|
+
} catch (e) {
|
|
595
|
+
return -1;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Wake Monitor Utility
|
|
601
|
+
*/
|
|
602
|
+
const wakeMonitor = async (force = false) => {
|
|
603
|
+
if (!config.wakeMonitor || !$) return;
|
|
604
|
+
try {
|
|
605
|
+
const idleSeconds = await getSystemIdleSeconds();
|
|
606
|
+
const threshold = config.idleThresholdSeconds || 30;
|
|
607
|
+
|
|
608
|
+
if (!force && idleSeconds < threshold) {
|
|
609
|
+
debugLog(`wakeMonitor: skipped (idle ${idleSeconds}s < ${threshold}s)`);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
debugLog(`wakeMonitor: attempting to wake monitor (idle: ${idleSeconds}s, force: ${force})`);
|
|
614
|
+
|
|
615
|
+
if (platform === 'win32') {
|
|
616
|
+
const cmd = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('{F15}')`;
|
|
617
|
+
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
618
|
+
debugLog('wakeMonitor: Windows wake command executed');
|
|
619
|
+
} else if (platform === 'darwin') {
|
|
620
|
+
await $`caffeinate -u -t 1`.quiet();
|
|
621
|
+
debugLog('wakeMonitor: macOS wake command executed');
|
|
622
|
+
} else if (platform === 'linux' && linux) {
|
|
623
|
+
// Use the Linux platform module for wake monitor
|
|
624
|
+
await linux.wakeMonitor();
|
|
625
|
+
debugLog('wakeMonitor: Linux wake command executed');
|
|
626
|
+
}
|
|
627
|
+
} catch (e) {
|
|
628
|
+
debugLog(`wakeMonitor error: ${e.message}`);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Force Volume Utility
|
|
634
|
+
*/
|
|
635
|
+
const forceVolume = async (force = false) => {
|
|
636
|
+
if (!config.forceVolume || !$) return;
|
|
637
|
+
try {
|
|
638
|
+
if (!force) {
|
|
639
|
+
const currentVolume = await getCurrentVolume();
|
|
640
|
+
const volumeThreshold = config.volumeThreshold || 50;
|
|
641
|
+
if (currentVolume >= 0 && currentVolume >= volumeThreshold) return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (platform === 'win32') {
|
|
645
|
+
const cmd = `$wsh = New-Object -ComObject WScript.Shell; 1..50 | ForEach-Object { $wsh.SendKeys([char]175) }`;
|
|
646
|
+
await $`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${cmd}`.quiet();
|
|
647
|
+
} else if (platform === 'darwin') {
|
|
648
|
+
await $`osascript -e "set volume output volume 100"`.quiet();
|
|
649
|
+
} else if (platform === 'linux' && linux) {
|
|
650
|
+
// Use the Linux platform module for force volume
|
|
651
|
+
await linux.forceVolume();
|
|
652
|
+
}
|
|
653
|
+
} catch (e) {
|
|
654
|
+
debugLog(`forceVolume error: ${e.message}`);
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
658
|
/**
|
|
659
659
|
* Main Speak function with fallback chain
|
|
660
660
|
* Cascade: Primary Engine -> Edge TTS -> Windows SAPI -> macOS Say -> Sound File
|
|
@@ -704,17 +704,17 @@ public static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
|
|
|
704
704
|
}
|
|
705
705
|
return false;
|
|
706
706
|
};
|
|
707
|
-
|
|
708
|
-
return {
|
|
709
|
-
speak,
|
|
710
|
-
announce: async (message, options = {}) => {
|
|
711
|
-
await wakeMonitor();
|
|
712
|
-
await forceVolume();
|
|
713
|
-
return speak(message, options);
|
|
714
|
-
},
|
|
715
|
-
wakeMonitor,
|
|
716
|
-
forceVolume,
|
|
717
|
-
playAudioFile,
|
|
718
|
-
config
|
|
719
|
-
};
|
|
720
|
-
};
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
speak,
|
|
710
|
+
announce: async (message, options = {}) => {
|
|
711
|
+
await wakeMonitor();
|
|
712
|
+
await forceVolume();
|
|
713
|
+
return speak(message, options);
|
|
714
|
+
},
|
|
715
|
+
wakeMonitor,
|
|
716
|
+
forceVolume,
|
|
717
|
+
playAudioFile,
|
|
718
|
+
config
|
|
719
|
+
};
|
|
720
|
+
};
|