opencode-smart-voice-notify 1.2.2 → 1.2.3
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/README.md +167 -403
- package/example.config.jsonc +356 -356
- package/index.js +978 -978
- package/package.json +52 -48
- package/util/ai-messages.js +205 -205
- package/util/config.js +441 -441
package/index.js
CHANGED
|
@@ -1,978 +1,978 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { createTTS, getTTSConfig } from './util/tts.js';
|
|
5
|
-
import { getSmartMessage } from './util/ai-messages.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* OpenCode Smart Voice Notify Plugin
|
|
9
|
-
*
|
|
10
|
-
* A smart notification plugin with multiple TTS engines (auto-fallback):
|
|
11
|
-
* 1. ElevenLabs (Online, High Quality, Anime-like voices)
|
|
12
|
-
* 2. Edge TTS (Free, Neural voices)
|
|
13
|
-
* 3. Windows SAPI (Offline, Built-in)
|
|
14
|
-
* 4. Local Sound Files (Fallback)
|
|
15
|
-
*
|
|
16
|
-
* Features:
|
|
17
|
-
* - Smart notification mode (sound-first, tts-first, both, sound-only)
|
|
18
|
-
* - Delayed TTS reminders if user doesn't respond
|
|
19
|
-
* - Follow-up reminders with exponential backoff
|
|
20
|
-
* - Monitor wake and volume boost
|
|
21
|
-
* - Cross-platform support (Windows, macOS, Linux)
|
|
22
|
-
*
|
|
23
|
-
* @type {import("@opencode-ai/plugin").Plugin}
|
|
24
|
-
*/
|
|
25
|
-
export default async function SmartVoiceNotifyPlugin({ project, client, $, directory, worktree }) {
|
|
26
|
-
const config = getTTSConfig();
|
|
27
|
-
|
|
28
|
-
// Master switch: if plugin is disabled, return empty handlers immediately
|
|
29
|
-
if (config.enabled === false) {
|
|
30
|
-
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
31
|
-
const logsDir = path.join(configDir, 'logs');
|
|
32
|
-
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
33
|
-
if (config.debugLog) {
|
|
34
|
-
try {
|
|
35
|
-
if (!fs.existsSync(logsDir)) {
|
|
36
|
-
fs.mkdirSync(logsDir, { recursive: true });
|
|
37
|
-
}
|
|
38
|
-
const timestamp = new Date().toISOString();
|
|
39
|
-
fs.appendFileSync(logFile, `[${timestamp}] Plugin disabled via config (enabled: false) - no event handlers registered\n`);
|
|
40
|
-
} catch (e) {}
|
|
41
|
-
}
|
|
42
|
-
return {};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const tts = createTTS({ $, client });
|
|
46
|
-
|
|
47
|
-
const platform = os.platform();
|
|
48
|
-
|
|
49
|
-
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
50
|
-
const logsDir = path.join(configDir, 'logs');
|
|
51
|
-
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
52
|
-
|
|
53
|
-
// Ensure logs directory exists if debug logging is enabled
|
|
54
|
-
if (config.debugLog && !fs.existsSync(logsDir)) {
|
|
55
|
-
try {
|
|
56
|
-
fs.mkdirSync(logsDir, { recursive: true });
|
|
57
|
-
} catch (e) {
|
|
58
|
-
// Silently fail - logging is optional
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Track pending TTS reminders (can be cancelled if user responds)
|
|
63
|
-
const pendingReminders = new Map();
|
|
64
|
-
|
|
65
|
-
// Track last user activity time
|
|
66
|
-
let lastUserActivityTime = Date.now();
|
|
67
|
-
|
|
68
|
-
// Track seen user message IDs to avoid treating message UPDATES as new user activity
|
|
69
|
-
// Key insight: message.updated fires for EVERY modification to a message, not just new messages
|
|
70
|
-
// We only want to treat the FIRST occurrence of each user message as "user activity"
|
|
71
|
-
const seenUserMessageIds = new Set();
|
|
72
|
-
|
|
73
|
-
// Track the timestamp of when session went idle, to detect post-idle user messages
|
|
74
|
-
let lastSessionIdleTime = 0;
|
|
75
|
-
|
|
76
|
-
// Track active permission request to prevent race condition where user responds
|
|
77
|
-
// before async notification code runs. Set on permission.updated, cleared on permission.replied.
|
|
78
|
-
let activePermissionId = null;
|
|
79
|
-
|
|
80
|
-
// ========================================
|
|
81
|
-
// PERMISSION BATCHING STATE
|
|
82
|
-
// Batches multiple simultaneous permission requests into a single notification
|
|
83
|
-
// ========================================
|
|
84
|
-
|
|
85
|
-
// Array of permission IDs waiting to be notified (collected during batch window)
|
|
86
|
-
let pendingPermissionBatch = [];
|
|
87
|
-
|
|
88
|
-
// Timeout ID for the batch window (debounce timer)
|
|
89
|
-
let permissionBatchTimeout = null;
|
|
90
|
-
|
|
91
|
-
// Batch window duration in milliseconds (how long to wait for more permissions)
|
|
92
|
-
const PERMISSION_BATCH_WINDOW_MS = config.permissionBatchWindowMs || 800;
|
|
93
|
-
|
|
94
|
-
// ========================================
|
|
95
|
-
// QUESTION BATCHING STATE (SDK v1.1.7+)
|
|
96
|
-
// Batches multiple simultaneous question requests into a single notification
|
|
97
|
-
// ========================================
|
|
98
|
-
|
|
99
|
-
// Array of question request objects waiting to be notified (collected during batch window)
|
|
100
|
-
// Each object contains { id: string, questionCount: number } to track actual question count
|
|
101
|
-
let pendingQuestionBatch = [];
|
|
102
|
-
|
|
103
|
-
// Timeout ID for the question batch window (debounce timer)
|
|
104
|
-
let questionBatchTimeout = null;
|
|
105
|
-
|
|
106
|
-
// Batch window duration in milliseconds (how long to wait for more questions)
|
|
107
|
-
const QUESTION_BATCH_WINDOW_MS = config.questionBatchWindowMs || 800;
|
|
108
|
-
|
|
109
|
-
// Track active question request to prevent race condition where user responds
|
|
110
|
-
// before async notification code runs. Set on question.asked, cleared on question.replied/rejected.
|
|
111
|
-
let activeQuestionId = null;
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Write debug message to log file
|
|
115
|
-
*/
|
|
116
|
-
const debugLog = (message) => {
|
|
117
|
-
if (!config.debugLog) return;
|
|
118
|
-
try {
|
|
119
|
-
const timestamp = new Date().toISOString();
|
|
120
|
-
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
121
|
-
} catch (e) {}
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Get a random message from an array of messages
|
|
126
|
-
*/
|
|
127
|
-
const getRandomMessage = (messages) => {
|
|
128
|
-
if (!Array.isArray(messages) || messages.length === 0) {
|
|
129
|
-
return 'Notification';
|
|
130
|
-
}
|
|
131
|
-
return messages[Math.floor(Math.random() * messages.length)];
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Show a TUI toast notification
|
|
136
|
-
*/
|
|
137
|
-
const showToast = async (message, variant = 'info', duration = 5000) => {
|
|
138
|
-
if (!config.enableToast) return;
|
|
139
|
-
try {
|
|
140
|
-
if (typeof client?.tui?.showToast === 'function') {
|
|
141
|
-
await client.tui.showToast({
|
|
142
|
-
body: {
|
|
143
|
-
message: message,
|
|
144
|
-
variant: variant,
|
|
145
|
-
duration: duration
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
} catch (e) {}
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Play a sound file from assets
|
|
154
|
-
*/
|
|
155
|
-
const playSound = async (soundFile, loops = 1) => {
|
|
156
|
-
if (!config.enableSound) return;
|
|
157
|
-
try {
|
|
158
|
-
const soundPath = path.isAbsolute(soundFile)
|
|
159
|
-
? soundFile
|
|
160
|
-
: path.join(configDir, soundFile);
|
|
161
|
-
|
|
162
|
-
if (!fs.existsSync(soundPath)) {
|
|
163
|
-
debugLog(`playSound: file not found: ${soundPath}`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
await tts.wakeMonitor();
|
|
168
|
-
await tts.forceVolume();
|
|
169
|
-
await tts.playAudioFile(soundPath, loops);
|
|
170
|
-
debugLog(`playSound: played ${soundPath} (${loops}x)`);
|
|
171
|
-
} catch (e) {
|
|
172
|
-
debugLog(`playSound error: ${e.message}`);
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Cancel any pending TTS reminder for a given type
|
|
178
|
-
*/
|
|
179
|
-
const cancelPendingReminder = (type) => {
|
|
180
|
-
const existing = pendingReminders.get(type);
|
|
181
|
-
if (existing) {
|
|
182
|
-
clearTimeout(existing.timeoutId);
|
|
183
|
-
pendingReminders.delete(type);
|
|
184
|
-
debugLog(`cancelPendingReminder: cancelled ${type}`);
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Cancel all pending TTS reminders (called on user activity)
|
|
190
|
-
*/
|
|
191
|
-
const cancelAllPendingReminders = () => {
|
|
192
|
-
for (const [type, reminder] of pendingReminders.entries()) {
|
|
193
|
-
clearTimeout(reminder.timeoutId);
|
|
194
|
-
debugLog(`cancelAllPendingReminders: cancelled ${type}`);
|
|
195
|
-
}
|
|
196
|
-
pendingReminders.clear();
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Schedule a TTS reminder if user doesn't respond within configured delay.
|
|
201
|
-
* The reminder uses a personalized TTS message.
|
|
202
|
-
* @param {string} type - 'idle', 'permission', or 'question'
|
|
203
|
-
* @param {string} message - The TTS message to speak (used directly, supports count-aware messages)
|
|
204
|
-
* @param {object} options - Additional options (fallbackSound, permissionCount, questionCount)
|
|
205
|
-
*/
|
|
206
|
-
const scheduleTTSReminder = (type, message, options = {}) => {
|
|
207
|
-
// Check if TTS reminders are enabled
|
|
208
|
-
if (!config.enableTTSReminder) {
|
|
209
|
-
debugLog(`scheduleTTSReminder: TTS reminders disabled`);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Get delay from config (in seconds, convert to ms)
|
|
214
|
-
let delaySeconds;
|
|
215
|
-
if (type === 'permission') {
|
|
216
|
-
delaySeconds = config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
|
|
217
|
-
} else if (type === 'question') {
|
|
218
|
-
delaySeconds = config.questionReminderDelaySeconds || config.ttsReminderDelaySeconds || 25;
|
|
219
|
-
} else {
|
|
220
|
-
delaySeconds = config.idleReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
|
|
221
|
-
}
|
|
222
|
-
const delayMs = delaySeconds * 1000;
|
|
223
|
-
|
|
224
|
-
// Cancel any existing reminder of this type
|
|
225
|
-
cancelPendingReminder(type);
|
|
226
|
-
|
|
227
|
-
// Store count for generating count-aware messages in reminders
|
|
228
|
-
const itemCount = options.permissionCount || options.questionCount || 1;
|
|
229
|
-
|
|
230
|
-
debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s (count=${itemCount})`);
|
|
231
|
-
|
|
232
|
-
const timeoutId = setTimeout(async () => {
|
|
233
|
-
try {
|
|
234
|
-
// Check if reminder was cancelled (user responded)
|
|
235
|
-
if (!pendingReminders.has(type)) {
|
|
236
|
-
debugLog(`scheduleTTSReminder: ${type} was cancelled before firing`);
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Check if user has been active since notification
|
|
241
|
-
const reminder = pendingReminders.get(type);
|
|
242
|
-
if (reminder && lastUserActivityTime > reminder.scheduledAt) {
|
|
243
|
-
debugLog(`scheduleTTSReminder: ${type} skipped - user active since notification`);
|
|
244
|
-
pendingReminders.delete(type);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
debugLog(`scheduleTTSReminder: firing ${type} TTS reminder (count=${reminder?.itemCount || 1})`);
|
|
249
|
-
|
|
250
|
-
// Get the appropriate reminder message
|
|
251
|
-
// For permissions/questions with count > 1, use the count-aware message generator
|
|
252
|
-
const storedCount = reminder?.itemCount || 1;
|
|
253
|
-
let reminderMessage;
|
|
254
|
-
if (type === 'permission') {
|
|
255
|
-
reminderMessage = await getPermissionMessage(storedCount, true);
|
|
256
|
-
} else if (type === 'question') {
|
|
257
|
-
reminderMessage = await getQuestionMessage(storedCount, true);
|
|
258
|
-
} else {
|
|
259
|
-
reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Check for ElevenLabs API key configuration issues
|
|
263
|
-
// If user hasn't responded (reminder firing) and config is missing, warn about fallback
|
|
264
|
-
if (config.ttsEngine === 'elevenlabs' && (!config.elevenLabsApiKey || config.elevenLabsApiKey.trim() === '')) {
|
|
265
|
-
debugLog('ElevenLabs API key missing during reminder - showing fallback toast');
|
|
266
|
-
await showToast("⚠️ ElevenLabs API Key missing! Falling back to Edge TTS.", "warning", 6000);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Speak the reminder using TTS
|
|
270
|
-
await tts.wakeMonitor();
|
|
271
|
-
await tts.forceVolume();
|
|
272
|
-
await tts.speak(reminderMessage, {
|
|
273
|
-
enableTTS: true,
|
|
274
|
-
fallbackSound: options.fallbackSound
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// CRITICAL FIX: Check if cancelled during playback (user responded while TTS was speaking)
|
|
278
|
-
if (!pendingReminders.has(type)) {
|
|
279
|
-
debugLog(`scheduleTTSReminder: ${type} cancelled during playback - aborting follow-up`);
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Clean up
|
|
284
|
-
pendingReminders.delete(type);
|
|
285
|
-
|
|
286
|
-
// Schedule follow-up reminder if configured (exponential backoff or fixed)
|
|
287
|
-
if (config.enableFollowUpReminders) {
|
|
288
|
-
const followUpCount = (reminder?.followUpCount || 0) + 1;
|
|
289
|
-
const maxFollowUps = config.maxFollowUpReminders || 3;
|
|
290
|
-
|
|
291
|
-
if (followUpCount < maxFollowUps) {
|
|
292
|
-
// Schedule another reminder with optional backoff
|
|
293
|
-
const backoffMultiplier = config.reminderBackoffMultiplier || 1.5;
|
|
294
|
-
const nextDelay = delaySeconds * Math.pow(backoffMultiplier, followUpCount);
|
|
295
|
-
|
|
296
|
-
debugLog(`scheduleTTSReminder: scheduling follow-up ${followUpCount + 1}/${maxFollowUps} in ${nextDelay}s`);
|
|
297
|
-
|
|
298
|
-
const followUpTimeoutId = setTimeout(async () => {
|
|
299
|
-
const followUpReminder = pendingReminders.get(type);
|
|
300
|
-
if (!followUpReminder || lastUserActivityTime > followUpReminder.scheduledAt) {
|
|
301
|
-
pendingReminders.delete(type);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Use count-aware message for follow-ups too
|
|
306
|
-
const followUpStoredCount = followUpReminder?.itemCount || 1;
|
|
307
|
-
let followUpMessage;
|
|
308
|
-
if (type === 'permission') {
|
|
309
|
-
followUpMessage = await getPermissionMessage(followUpStoredCount, true);
|
|
310
|
-
} else if (type === 'question') {
|
|
311
|
-
followUpMessage = await getQuestionMessage(followUpStoredCount, true);
|
|
312
|
-
} else {
|
|
313
|
-
followUpMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
await tts.wakeMonitor();
|
|
317
|
-
await tts.forceVolume();
|
|
318
|
-
await tts.speak(followUpMessage, {
|
|
319
|
-
enableTTS: true,
|
|
320
|
-
fallbackSound: options.fallbackSound
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
pendingReminders.delete(type);
|
|
324
|
-
}, nextDelay * 1000);
|
|
325
|
-
|
|
326
|
-
pendingReminders.set(type, {
|
|
327
|
-
timeoutId: followUpTimeoutId,
|
|
328
|
-
scheduledAt: Date.now(),
|
|
329
|
-
followUpCount,
|
|
330
|
-
itemCount: storedCount // Preserve the count for follow-ups
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
} catch (e) {
|
|
335
|
-
debugLog(`scheduleTTSReminder error: ${e.message}`);
|
|
336
|
-
pendingReminders.delete(type);
|
|
337
|
-
}
|
|
338
|
-
}, delayMs);
|
|
339
|
-
|
|
340
|
-
// Store the pending reminder with item count
|
|
341
|
-
pendingReminders.set(type, {
|
|
342
|
-
timeoutId,
|
|
343
|
-
scheduledAt: Date.now(),
|
|
344
|
-
followUpCount: 0,
|
|
345
|
-
itemCount // Store count for later use
|
|
346
|
-
});
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Smart notification: play sound first, then schedule TTS reminder
|
|
351
|
-
* @param {string} type - 'idle', 'permission', or 'question'
|
|
352
|
-
* @param {object} options - Notification options
|
|
353
|
-
*/
|
|
354
|
-
const smartNotify = async (type, options = {}) => {
|
|
355
|
-
const {
|
|
356
|
-
soundFile,
|
|
357
|
-
soundLoops = 1,
|
|
358
|
-
ttsMessage,
|
|
359
|
-
fallbackSound,
|
|
360
|
-
permissionCount, // Support permission count for batched notifications
|
|
361
|
-
questionCount // Support question count for batched notifications
|
|
362
|
-
} = options;
|
|
363
|
-
|
|
364
|
-
// Step 1: Play the immediate sound notification
|
|
365
|
-
if (soundFile) {
|
|
366
|
-
await playSound(soundFile, soundLoops);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// CRITICAL FIX: Check if user responded during sound playback
|
|
370
|
-
// For idle notifications: check if there was new activity after the idle start
|
|
371
|
-
if (type === 'idle' && lastUserActivityTime > lastSessionIdleTime) {
|
|
372
|
-
debugLog(`smartNotify: user active during sound - aborting idle reminder`);
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
// For permission notifications: check if the permission was already handled
|
|
376
|
-
if (type === 'permission' && !activePermissionId) {
|
|
377
|
-
debugLog(`smartNotify: permission handled during sound - aborting reminder`);
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
// For question notifications: check if the question was already answered/rejected
|
|
381
|
-
if (type === 'question' && !activeQuestionId) {
|
|
382
|
-
debugLog(`smartNotify: question handled during sound - aborting reminder`);
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Step 2: Schedule TTS reminder if user doesn't respond
|
|
387
|
-
if (config.enableTTSReminder && ttsMessage) {
|
|
388
|
-
scheduleTTSReminder(type, ttsMessage, { fallbackSound, permissionCount, questionCount });
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Step 3: If TTS-first mode is enabled, also speak immediately
|
|
392
|
-
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
393
|
-
let immediateMessage;
|
|
394
|
-
if (type === 'permission') {
|
|
395
|
-
immediateMessage = await getSmartMessage('permission', false, config.permissionTTSMessages);
|
|
396
|
-
} else if (type === 'question') {
|
|
397
|
-
immediateMessage = await getSmartMessage('question', false, config.questionTTSMessages);
|
|
398
|
-
} else {
|
|
399
|
-
immediateMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
await tts.speak(immediateMessage, {
|
|
403
|
-
enableTTS: true,
|
|
404
|
-
fallbackSound
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Get a count-aware TTS message for permission requests
|
|
411
|
-
* Uses AI generation when enabled, falls back to static messages
|
|
412
|
-
* @param {number} count - Number of permission requests
|
|
413
|
-
* @param {boolean} isReminder - Whether this is a reminder message
|
|
414
|
-
* @returns {Promise<string>} The formatted message
|
|
415
|
-
*/
|
|
416
|
-
const getPermissionMessage = async (count, isReminder = false) => {
|
|
417
|
-
const messages = isReminder
|
|
418
|
-
? config.permissionReminderTTSMessages
|
|
419
|
-
: config.permissionTTSMessages;
|
|
420
|
-
|
|
421
|
-
// If AI messages are enabled, ALWAYS try AI first (regardless of count)
|
|
422
|
-
if (config.enableAIMessages) {
|
|
423
|
-
const aiMessage = await getSmartMessage('permission', isReminder, messages, { count, type: 'permission' });
|
|
424
|
-
// getSmartMessage returns static message as fallback, so if AI was attempted
|
|
425
|
-
// and succeeded, we'll get the AI message. If it failed, we get static.
|
|
426
|
-
// Check if we got a valid message (not the generic fallback)
|
|
427
|
-
if (aiMessage && aiMessage !== 'Notification') {
|
|
428
|
-
return aiMessage;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Fallback to static messages (AI disabled or failed with generic fallback)
|
|
433
|
-
if (count === 1) {
|
|
434
|
-
return getRandomMessage(messages);
|
|
435
|
-
} else {
|
|
436
|
-
const countMessages = isReminder
|
|
437
|
-
? config.permissionReminderTTSMessagesMultiple
|
|
438
|
-
: config.permissionTTSMessagesMultiple;
|
|
439
|
-
|
|
440
|
-
if (countMessages && countMessages.length > 0) {
|
|
441
|
-
const template = getRandomMessage(countMessages);
|
|
442
|
-
return template.replace('{count}', count.toString());
|
|
443
|
-
}
|
|
444
|
-
return `Attention! There are ${count} permission requests waiting for your approval.`;
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* Get a count-aware TTS message for question requests (SDK v1.1.7+)
|
|
450
|
-
* Uses AI generation when enabled, falls back to static messages
|
|
451
|
-
* @param {number} count - Number of question requests
|
|
452
|
-
* @param {boolean} isReminder - Whether this is a reminder message
|
|
453
|
-
* @returns {Promise<string>} The formatted message
|
|
454
|
-
*/
|
|
455
|
-
const getQuestionMessage = async (count, isReminder = false) => {
|
|
456
|
-
const messages = isReminder
|
|
457
|
-
? config.questionReminderTTSMessages
|
|
458
|
-
: config.questionTTSMessages;
|
|
459
|
-
|
|
460
|
-
// If AI messages are enabled, ALWAYS try AI first (regardless of count)
|
|
461
|
-
if (config.enableAIMessages) {
|
|
462
|
-
const aiMessage = await getSmartMessage('question', isReminder, messages, { count, type: 'question' });
|
|
463
|
-
// getSmartMessage returns static message as fallback, so if AI was attempted
|
|
464
|
-
// and succeeded, we'll get the AI message. If it failed, we get static.
|
|
465
|
-
// Check if we got a valid message (not the generic fallback)
|
|
466
|
-
if (aiMessage && aiMessage !== 'Notification') {
|
|
467
|
-
return aiMessage;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Fallback to static messages (AI disabled or failed with generic fallback)
|
|
472
|
-
if (count === 1) {
|
|
473
|
-
return getRandomMessage(messages);
|
|
474
|
-
} else {
|
|
475
|
-
const countMessages = isReminder
|
|
476
|
-
? config.questionReminderTTSMessagesMultiple
|
|
477
|
-
: config.questionTTSMessagesMultiple;
|
|
478
|
-
|
|
479
|
-
if (countMessages && countMessages.length > 0) {
|
|
480
|
-
const template = getRandomMessage(countMessages);
|
|
481
|
-
return template.replace('{count}', count.toString());
|
|
482
|
-
}
|
|
483
|
-
return `Hey! I have ${count} questions for you. Please check your screen.`;
|
|
484
|
-
}
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Process the batched permission requests as a single notification
|
|
489
|
-
* Called after the batch window expires
|
|
490
|
-
*
|
|
491
|
-
* FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
|
|
492
|
-
* AI message generation can take 3-15+ seconds, which was delaying sound playback.
|
|
493
|
-
*/
|
|
494
|
-
const processPermissionBatch = async () => {
|
|
495
|
-
// Capture and clear the batch
|
|
496
|
-
const batch = [...pendingPermissionBatch];
|
|
497
|
-
const batchCount = batch.length;
|
|
498
|
-
pendingPermissionBatch = [];
|
|
499
|
-
permissionBatchTimeout = null;
|
|
500
|
-
|
|
501
|
-
if (batchCount === 0) {
|
|
502
|
-
debugLog('processPermissionBatch: empty batch, skipping');
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
debugLog(`processPermissionBatch: processing ${batchCount} permission(s)`);
|
|
507
|
-
|
|
508
|
-
// Set activePermissionId to the first one (for race condition checks)
|
|
509
|
-
// We track all IDs in the batch for proper cleanup
|
|
510
|
-
activePermissionId = batch[0];
|
|
511
|
-
|
|
512
|
-
// Step 1: Show toast IMMEDIATELY (fire and forget - no await)
|
|
513
|
-
const toastMessage = batchCount === 1
|
|
514
|
-
? "⚠️ Permission request requires your attention"
|
|
515
|
-
: `⚠️ ${batchCount} permission requests require your attention`;
|
|
516
|
-
showToast(toastMessage, "warning", 8000); // No await - instant display
|
|
517
|
-
|
|
518
|
-
// Step 2: Play sound (after toast is triggered)
|
|
519
|
-
const soundLoops = batchCount === 1 ? 2 : Math.min(3, batchCount);
|
|
520
|
-
await playSound(config.permissionSound, soundLoops);
|
|
521
|
-
|
|
522
|
-
// CHECK: Did user already respond while sound was playing?
|
|
523
|
-
if (pendingPermissionBatch.length > 0) {
|
|
524
|
-
// New permissions arrived during sound - they'll be handled in next batch
|
|
525
|
-
debugLog('processPermissionBatch: new permissions arrived during sound');
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Step 3: Check race condition - did user respond during sound?
|
|
529
|
-
if (activePermissionId === null) {
|
|
530
|
-
debugLog('processPermissionBatch: user responded during sound - aborting');
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Step 4: Generate AI message for reminder AFTER sound played
|
|
535
|
-
const reminderMessage = await getPermissionMessage(batchCount, true);
|
|
536
|
-
|
|
537
|
-
// Step 5: Schedule TTS reminder if enabled
|
|
538
|
-
if (config.enableTTSReminder && reminderMessage) {
|
|
539
|
-
scheduleTTSReminder('permission', reminderMessage, {
|
|
540
|
-
fallbackSound: config.permissionSound,
|
|
541
|
-
permissionCount: batchCount
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Step 6: If TTS-first or both mode, generate and speak immediate message
|
|
546
|
-
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
547
|
-
const ttsMessage = await getPermissionMessage(batchCount, false);
|
|
548
|
-
await tts.wakeMonitor();
|
|
549
|
-
await tts.forceVolume();
|
|
550
|
-
await tts.speak(ttsMessage, {
|
|
551
|
-
enableTTS: true,
|
|
552
|
-
fallbackSound: config.permissionSound
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// Final check: if user responded during notification, cancel scheduled reminder
|
|
557
|
-
if (activePermissionId === null) {
|
|
558
|
-
debugLog('processPermissionBatch: user responded during notification - cancelling reminder');
|
|
559
|
-
cancelPendingReminder('permission');
|
|
560
|
-
}
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Process the batched question requests as a single notification (SDK v1.1.7+)
|
|
565
|
-
* Called after the batch window expires
|
|
566
|
-
*
|
|
567
|
-
* FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
|
|
568
|
-
* AI message generation can take 3-15+ seconds, which was delaying sound playback.
|
|
569
|
-
*/
|
|
570
|
-
const processQuestionBatch = async () => {
|
|
571
|
-
// Capture and clear the batch
|
|
572
|
-
const batch = [...pendingQuestionBatch];
|
|
573
|
-
pendingQuestionBatch = [];
|
|
574
|
-
questionBatchTimeout = null;
|
|
575
|
-
|
|
576
|
-
if (batch.length === 0) {
|
|
577
|
-
debugLog('processQuestionBatch: empty batch, skipping');
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// Calculate total number of questions across all batched requests
|
|
582
|
-
// Each batch item is { id, questionCount } where questionCount is the number of questions in that request
|
|
583
|
-
const totalQuestionCount = batch.reduce((sum, item) => sum + (item.questionCount || 1), 0);
|
|
584
|
-
|
|
585
|
-
debugLog(`processQuestionBatch: processing ${batch.length} request(s) with ${totalQuestionCount} total question(s)`);
|
|
586
|
-
|
|
587
|
-
// Set activeQuestionId to the first one (for race condition checks)
|
|
588
|
-
// We track all IDs in the batch for proper cleanup
|
|
589
|
-
activeQuestionId = batch[0]?.id;
|
|
590
|
-
|
|
591
|
-
// Step 1: Show toast IMMEDIATELY (fire and forget - no await)
|
|
592
|
-
const toastMessage = totalQuestionCount === 1
|
|
593
|
-
? "❓ The agent has a question for you"
|
|
594
|
-
: `❓ The agent has ${totalQuestionCount} questions for you`;
|
|
595
|
-
showToast(toastMessage, "info", 8000); // No await - instant display
|
|
596
|
-
|
|
597
|
-
// Step 2: Play sound (after toast is triggered)
|
|
598
|
-
await playSound(config.questionSound, 2);
|
|
599
|
-
|
|
600
|
-
// CHECK: Did user already respond while sound was playing?
|
|
601
|
-
if (pendingQuestionBatch.length > 0) {
|
|
602
|
-
// New questions arrived during sound - they'll be handled in next batch
|
|
603
|
-
debugLog('processQuestionBatch: new questions arrived during sound');
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Step 3: Check race condition - did user respond during sound?
|
|
607
|
-
if (activeQuestionId === null) {
|
|
608
|
-
debugLog('processQuestionBatch: user responded during sound - aborting');
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Step 4: Generate AI message for reminder AFTER sound played
|
|
613
|
-
const reminderMessage = await getQuestionMessage(totalQuestionCount, true);
|
|
614
|
-
|
|
615
|
-
// Step 5: Schedule TTS reminder if enabled
|
|
616
|
-
if (config.enableTTSReminder && reminderMessage) {
|
|
617
|
-
scheduleTTSReminder('question', reminderMessage, {
|
|
618
|
-
fallbackSound: config.questionSound,
|
|
619
|
-
questionCount: totalQuestionCount
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Step 6: If TTS-first or both mode, generate and speak immediate message
|
|
624
|
-
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
625
|
-
const ttsMessage = await getQuestionMessage(totalQuestionCount, false);
|
|
626
|
-
await tts.wakeMonitor();
|
|
627
|
-
await tts.forceVolume();
|
|
628
|
-
await tts.speak(ttsMessage, {
|
|
629
|
-
enableTTS: true,
|
|
630
|
-
fallbackSound: config.questionSound
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Final check: if user responded during notification, cancel scheduled reminder
|
|
635
|
-
if (activeQuestionId === null) {
|
|
636
|
-
debugLog('processQuestionBatch: user responded during notification - cancelling reminder');
|
|
637
|
-
cancelPendingReminder('question');
|
|
638
|
-
}
|
|
639
|
-
};
|
|
640
|
-
|
|
641
|
-
return {
|
|
642
|
-
event: async ({ event }) => {
|
|
643
|
-
try {
|
|
644
|
-
// ========================================
|
|
645
|
-
// USER ACTIVITY DETECTION
|
|
646
|
-
// Cancels pending TTS reminders when user responds
|
|
647
|
-
// ========================================
|
|
648
|
-
// NOTE: OpenCode event types (supporting SDK v1.0.x, v1.1.x, and v1.1.7+):
|
|
649
|
-
// - message.updated: fires when a message is added/updated (use properties.info.role to check user vs assistant)
|
|
650
|
-
// - permission.updated (SDK v1.0.x): fires when a permission request is created
|
|
651
|
-
// - permission.asked (SDK v1.1.1+): fires when a permission request is created (replaces permission.updated)
|
|
652
|
-
// - permission.replied: fires when user responds to a permission request
|
|
653
|
-
// - SDK v1.0.x: uses permissionID, response
|
|
654
|
-
// - SDK v1.1.1+: uses requestID, reply
|
|
655
|
-
// - question.asked (SDK v1.1.7+): fires when agent asks user a question
|
|
656
|
-
// - question.replied (SDK v1.1.7+): fires when user answers a question
|
|
657
|
-
// - question.rejected (SDK v1.1.7+): fires when user dismisses a question
|
|
658
|
-
// - session.created: fires when a new session starts
|
|
659
|
-
//
|
|
660
|
-
// CRITICAL: message.updated fires for EVERY modification to a message (not just creation).
|
|
661
|
-
// Context-injector and other plugins can trigger multiple updates for the same message.
|
|
662
|
-
// We must only treat NEW user messages (after session.idle) as actual user activity.
|
|
663
|
-
|
|
664
|
-
if (event.type === "message.updated") {
|
|
665
|
-
const messageInfo = event.properties?.info;
|
|
666
|
-
const messageId = messageInfo?.id;
|
|
667
|
-
const isUserMessage = messageInfo?.role === 'user';
|
|
668
|
-
|
|
669
|
-
if (isUserMessage && messageId) {
|
|
670
|
-
// Check if this is a NEW user message we haven't seen before
|
|
671
|
-
const isNewMessage = !seenUserMessageIds.has(messageId);
|
|
672
|
-
|
|
673
|
-
// Check if this message arrived AFTER the last session.idle
|
|
674
|
-
// This is the key: only a message sent AFTER idle indicates user responded
|
|
675
|
-
const messageTime = messageInfo?.time?.created;
|
|
676
|
-
const isAfterIdle = lastSessionIdleTime > 0 && messageTime && (messageTime * 1000) > lastSessionIdleTime;
|
|
677
|
-
|
|
678
|
-
if (isNewMessage) {
|
|
679
|
-
seenUserMessageIds.add(messageId);
|
|
680
|
-
|
|
681
|
-
// Only cancel reminders if this is a NEW message AFTER session went idle
|
|
682
|
-
// OR if there are no pending reminders (initial message before any notifications)
|
|
683
|
-
if (isAfterIdle || pendingReminders.size === 0) {
|
|
684
|
-
if (isAfterIdle) {
|
|
685
|
-
lastUserActivityTime = Date.now();
|
|
686
|
-
cancelAllPendingReminders();
|
|
687
|
-
debugLog(`NEW user message AFTER idle: ${messageId} - cancelled pending reminders`);
|
|
688
|
-
} else {
|
|
689
|
-
debugLog(`Initial user message (before any idle): ${messageId} - no reminders to cancel`);
|
|
690
|
-
}
|
|
691
|
-
} else {
|
|
692
|
-
debugLog(`Ignored: user message ${messageId} created BEFORE session.idle (time=${messageTime}, idleTime=${lastSessionIdleTime})`);
|
|
693
|
-
}
|
|
694
|
-
} else {
|
|
695
|
-
// This is an UPDATE to an existing message (e.g., context injection)
|
|
696
|
-
debugLog(`Ignored: update to existing user message ${messageId} (not new activity)`);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (event.type === "permission.replied") {
|
|
702
|
-
// User responded to a permission request (granted or denied)
|
|
703
|
-
// Structure varies by SDK version:
|
|
704
|
-
// - Old SDK: event.properties.{ sessionID, permissionID, response }
|
|
705
|
-
// - New SDK (v1.1.1+): event.properties.{ sessionID, requestID, reply }
|
|
706
|
-
// CRITICAL: Clear activePermissionId FIRST to prevent race condition
|
|
707
|
-
// where permission.updated/asked handler is still running async operations
|
|
708
|
-
const repliedPermissionId = event.properties?.permissionID || event.properties?.requestID;
|
|
709
|
-
const response = event.properties?.response || event.properties?.reply;
|
|
710
|
-
|
|
711
|
-
// Remove this permission from the pending batch (if still waiting)
|
|
712
|
-
if (repliedPermissionId && pendingPermissionBatch.includes(repliedPermissionId)) {
|
|
713
|
-
pendingPermissionBatch = pendingPermissionBatch.filter(id => id !== repliedPermissionId);
|
|
714
|
-
debugLog(`Permission replied: removed ${repliedPermissionId} from pending batch (${pendingPermissionBatch.length} remaining)`);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// If batch is now empty and we have a pending batch timeout, we can cancel it
|
|
718
|
-
// (user responded to all permissions before batch window expired)
|
|
719
|
-
if (pendingPermissionBatch.length === 0 && permissionBatchTimeout) {
|
|
720
|
-
clearTimeout(permissionBatchTimeout);
|
|
721
|
-
permissionBatchTimeout = null;
|
|
722
|
-
debugLog('Permission replied: cancelled batch timeout (all permissions handled)');
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Match if IDs are equal, or if we have an active permission with unknown ID (undefined)
|
|
726
|
-
// (This happens if permission.updated/asked received an event without permissionID)
|
|
727
|
-
if (activePermissionId === repliedPermissionId || activePermissionId === undefined) {
|
|
728
|
-
activePermissionId = null;
|
|
729
|
-
debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId || '(unknown)'}`);
|
|
730
|
-
}
|
|
731
|
-
lastUserActivityTime = Date.now();
|
|
732
|
-
cancelPendingReminder('permission'); // Cancel permission-specific reminder
|
|
733
|
-
debugLog(`Permission replied: ${event.type} (response=${response}) - cancelled permission reminder`);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (event.type === "session.created") {
|
|
737
|
-
// New session started - reset tracking state
|
|
738
|
-
lastUserActivityTime = Date.now();
|
|
739
|
-
lastSessionIdleTime = 0;
|
|
740
|
-
activePermissionId = null;
|
|
741
|
-
activeQuestionId = null;
|
|
742
|
-
seenUserMessageIds.clear();
|
|
743
|
-
cancelAllPendingReminders();
|
|
744
|
-
|
|
745
|
-
// Reset permission batch state
|
|
746
|
-
pendingPermissionBatch = [];
|
|
747
|
-
if (permissionBatchTimeout) {
|
|
748
|
-
clearTimeout(permissionBatchTimeout);
|
|
749
|
-
permissionBatchTimeout = null;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// Reset question batch state
|
|
753
|
-
pendingQuestionBatch = [];
|
|
754
|
-
if (questionBatchTimeout) {
|
|
755
|
-
clearTimeout(questionBatchTimeout);
|
|
756
|
-
questionBatchTimeout = null;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
debugLog(`Session created: ${event.type} - reset all tracking state`);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// ========================================
|
|
763
|
-
// NOTIFICATION 1: Session Idle (Agent Finished)
|
|
764
|
-
//
|
|
765
|
-
// FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
|
|
766
|
-
// AI message generation can take 3-15+ seconds, which was delaying sound playback.
|
|
767
|
-
// ========================================
|
|
768
|
-
if (event.type === "session.idle") {
|
|
769
|
-
const sessionID = event.properties?.sessionID;
|
|
770
|
-
if (!sessionID) return;
|
|
771
|
-
|
|
772
|
-
try {
|
|
773
|
-
const session = await client.session.get({ path: { id: sessionID } });
|
|
774
|
-
if (session?.data?.parentID) {
|
|
775
|
-
debugLog(`session.idle: skipped (sub-session ${sessionID})`);
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
} catch (e) {}
|
|
779
|
-
|
|
780
|
-
// Record the time session went idle - used to filter out pre-idle messages
|
|
781
|
-
lastSessionIdleTime = Date.now();
|
|
782
|
-
|
|
783
|
-
debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
|
|
784
|
-
|
|
785
|
-
// Step 1: Show toast IMMEDIATELY (fire and forget - no await)
|
|
786
|
-
showToast("✅ Agent has finished working", "success", 5000); // No await - instant display
|
|
787
|
-
|
|
788
|
-
// Step 2: Play sound (after toast is triggered)
|
|
789
|
-
// Only play sound in sound-first, sound-only, or both mode
|
|
790
|
-
if (config.notificationMode !== 'tts-first') {
|
|
791
|
-
await playSound(config.idleSound, 1);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// Step 3: Check race condition - did user respond during sound?
|
|
795
|
-
if (lastUserActivityTime > lastSessionIdleTime) {
|
|
796
|
-
debugLog(`session.idle: user active during sound - aborting`);
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Step 4: Generate AI message for reminder AFTER sound played
|
|
801
|
-
const reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
|
|
802
|
-
|
|
803
|
-
// Step 5: Schedule TTS reminder if enabled
|
|
804
|
-
if (config.enableTTSReminder && reminderMessage) {
|
|
805
|
-
scheduleTTSReminder('idle', reminderMessage, {
|
|
806
|
-
fallbackSound: config.idleSound
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// Step 6: If TTS-first or both mode, generate and speak immediate message
|
|
811
|
-
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
812
|
-
const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
|
|
813
|
-
await tts.wakeMonitor();
|
|
814
|
-
await tts.forceVolume();
|
|
815
|
-
await tts.speak(ttsMessage, {
|
|
816
|
-
enableTTS: true,
|
|
817
|
-
fallbackSound: config.idleSound
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// ========================================
|
|
823
|
-
// NOTIFICATION 2: Permission Request (BATCHED)
|
|
824
|
-
// ========================================
|
|
825
|
-
// NOTE: OpenCode SDK v1.1.1+ changed permission events:
|
|
826
|
-
// - Old: "permission.updated" with properties.id
|
|
827
|
-
// - New: "permission.asked" with properties.id
|
|
828
|
-
// We support both for backward compatibility.
|
|
829
|
-
//
|
|
830
|
-
// BATCHING: When multiple permissions arrive simultaneously (e.g., 5 at once),
|
|
831
|
-
// we batch them into a single notification instead of playing 5 overlapping sounds.
|
|
832
|
-
if (event.type === "permission.updated" || event.type === "permission.asked") {
|
|
833
|
-
// Capture permissionID
|
|
834
|
-
const permissionId = event.properties?.id;
|
|
835
|
-
|
|
836
|
-
if (!permissionId) {
|
|
837
|
-
debugLog(`${event.type}: permission ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
// Add to the pending batch (avoid duplicates)
|
|
841
|
-
if (permissionId && !pendingPermissionBatch.includes(permissionId)) {
|
|
842
|
-
pendingPermissionBatch.push(permissionId);
|
|
843
|
-
debugLog(`${event.type}: added ${permissionId} to batch (now ${pendingPermissionBatch.length} pending)`);
|
|
844
|
-
} else if (!permissionId) {
|
|
845
|
-
// If no ID, still count it (use a placeholder)
|
|
846
|
-
pendingPermissionBatch.push(`unknown-${Date.now()}`);
|
|
847
|
-
debugLog(`${event.type}: added unknown permission to batch (now ${pendingPermissionBatch.length} pending)`);
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Reset the batch window timer (debounce)
|
|
851
|
-
// This gives more permissions a chance to arrive before we notify
|
|
852
|
-
if (permissionBatchTimeout) {
|
|
853
|
-
clearTimeout(permissionBatchTimeout);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
permissionBatchTimeout = setTimeout(async () => {
|
|
857
|
-
try {
|
|
858
|
-
await processPermissionBatch();
|
|
859
|
-
} catch (e) {
|
|
860
|
-
debugLog(`processPermissionBatch error: ${e.message}`);
|
|
861
|
-
}
|
|
862
|
-
}, PERMISSION_BATCH_WINDOW_MS);
|
|
863
|
-
|
|
864
|
-
debugLog(`${event.type}: batch window reset (will process in ${PERMISSION_BATCH_WINDOW_MS}ms if no more arrive)`);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// ========================================
|
|
868
|
-
// NOTIFICATION 3: Question Request (BATCHED) - SDK v1.1.7+
|
|
869
|
-
// ========================================
|
|
870
|
-
// The "question" tool allows the LLM to ask users questions during execution.
|
|
871
|
-
// Events: question.asked, question.replied, question.rejected
|
|
872
|
-
//
|
|
873
|
-
// BATCHING: When multiple question requests arrive simultaneously,
|
|
874
|
-
// we batch them into a single notification instead of playing overlapping sounds.
|
|
875
|
-
// NOTE: Each question.asked event can contain multiple questions in its questions array.
|
|
876
|
-
if (event.type === "question.asked") {
|
|
877
|
-
// Capture question request ID and count of questions in this request
|
|
878
|
-
const questionId = event.properties?.id;
|
|
879
|
-
const questionsArray = event.properties?.questions;
|
|
880
|
-
const questionCount = Array.isArray(questionsArray) ? questionsArray.length : 1;
|
|
881
|
-
|
|
882
|
-
if (!questionId) {
|
|
883
|
-
debugLog(`${event.type}: question ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// Add to the pending batch (avoid duplicates by checking ID)
|
|
887
|
-
// Store as object with id and questionCount for proper counting
|
|
888
|
-
const existingIndex = pendingQuestionBatch.findIndex(item => item.id === questionId);
|
|
889
|
-
if (questionId && existingIndex === -1) {
|
|
890
|
-
pendingQuestionBatch.push({ id: questionId, questionCount });
|
|
891
|
-
debugLog(`${event.type}: added ${questionId} with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
|
|
892
|
-
} else if (!questionId) {
|
|
893
|
-
// If no ID, still count it (use a placeholder)
|
|
894
|
-
pendingQuestionBatch.push({ id: `unknown-${Date.now()}`, questionCount });
|
|
895
|
-
debugLog(`${event.type}: added unknown question request with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Reset the batch window timer (debounce)
|
|
899
|
-
// This gives more questions a chance to arrive before we notify
|
|
900
|
-
if (questionBatchTimeout) {
|
|
901
|
-
clearTimeout(questionBatchTimeout);
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
questionBatchTimeout = setTimeout(async () => {
|
|
905
|
-
try {
|
|
906
|
-
await processQuestionBatch();
|
|
907
|
-
} catch (e) {
|
|
908
|
-
debugLog(`processQuestionBatch error: ${e.message}`);
|
|
909
|
-
}
|
|
910
|
-
}, QUESTION_BATCH_WINDOW_MS);
|
|
911
|
-
|
|
912
|
-
debugLog(`${event.type}: batch window reset (will process in ${QUESTION_BATCH_WINDOW_MS}ms if no more arrive)`);
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// Handle question.replied - user answered the question(s)
|
|
916
|
-
if (event.type === "question.replied") {
|
|
917
|
-
const repliedQuestionId = event.properties?.requestID;
|
|
918
|
-
const answers = event.properties?.answers;
|
|
919
|
-
|
|
920
|
-
// Remove this question from the pending batch (if still waiting)
|
|
921
|
-
// pendingQuestionBatch is now an array of { id, questionCount } objects
|
|
922
|
-
const existingIndex = pendingQuestionBatch.findIndex(item => item.id === repliedQuestionId);
|
|
923
|
-
if (repliedQuestionId && existingIndex !== -1) {
|
|
924
|
-
pendingQuestionBatch.splice(existingIndex, 1);
|
|
925
|
-
debugLog(`Question replied: removed ${repliedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// If batch is now empty and we have a pending batch timeout, we can cancel it
|
|
929
|
-
if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
|
|
930
|
-
clearTimeout(questionBatchTimeout);
|
|
931
|
-
questionBatchTimeout = null;
|
|
932
|
-
debugLog('Question replied: cancelled batch timeout (all questions handled)');
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
// Clear active question ID
|
|
936
|
-
if (activeQuestionId === repliedQuestionId || activeQuestionId === undefined) {
|
|
937
|
-
activeQuestionId = null;
|
|
938
|
-
debugLog(`Question replied: cleared activeQuestionId ${repliedQuestionId || '(unknown)'}`);
|
|
939
|
-
}
|
|
940
|
-
lastUserActivityTime = Date.now();
|
|
941
|
-
cancelPendingReminder('question'); // Cancel question-specific reminder
|
|
942
|
-
debugLog(`Question replied: ${event.type} (answers=${JSON.stringify(answers)}) - cancelled question reminder`);
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
// Handle question.rejected - user dismissed the question
|
|
946
|
-
if (event.type === "question.rejected") {
|
|
947
|
-
const rejectedQuestionId = event.properties?.requestID;
|
|
948
|
-
|
|
949
|
-
// Remove this question from the pending batch (if still waiting)
|
|
950
|
-
// pendingQuestionBatch is now an array of { id, questionCount } objects
|
|
951
|
-
const existingIndex = pendingQuestionBatch.findIndex(item => item.id === rejectedQuestionId);
|
|
952
|
-
if (rejectedQuestionId && existingIndex !== -1) {
|
|
953
|
-
pendingQuestionBatch.splice(existingIndex, 1);
|
|
954
|
-
debugLog(`Question rejected: removed ${rejectedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// If batch is now empty and we have a pending batch timeout, we can cancel it
|
|
958
|
-
if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
|
|
959
|
-
clearTimeout(questionBatchTimeout);
|
|
960
|
-
questionBatchTimeout = null;
|
|
961
|
-
debugLog('Question rejected: cancelled batch timeout (all questions handled)');
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// Clear active question ID
|
|
965
|
-
if (activeQuestionId === rejectedQuestionId || activeQuestionId === undefined) {
|
|
966
|
-
activeQuestionId = null;
|
|
967
|
-
debugLog(`Question rejected: cleared activeQuestionId ${rejectedQuestionId || '(unknown)'}`);
|
|
968
|
-
}
|
|
969
|
-
lastUserActivityTime = Date.now();
|
|
970
|
-
cancelPendingReminder('question'); // Cancel question-specific reminder
|
|
971
|
-
debugLog(`Question rejected: ${event.type} - cancelled question reminder`);
|
|
972
|
-
}
|
|
973
|
-
} catch (e) {
|
|
974
|
-
debugLog(`event handler error: ${e.message}`);
|
|
975
|
-
}
|
|
976
|
-
},
|
|
977
|
-
};
|
|
978
|
-
}
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { createTTS, getTTSConfig } from './util/tts.js';
|
|
5
|
+
import { getSmartMessage } from './util/ai-messages.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenCode Smart Voice Notify Plugin
|
|
9
|
+
*
|
|
10
|
+
* A smart notification plugin with multiple TTS engines (auto-fallback):
|
|
11
|
+
* 1. ElevenLabs (Online, High Quality, Anime-like voices)
|
|
12
|
+
* 2. Edge TTS (Free, Neural voices)
|
|
13
|
+
* 3. Windows SAPI (Offline, Built-in)
|
|
14
|
+
* 4. Local Sound Files (Fallback)
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - Smart notification mode (sound-first, tts-first, both, sound-only)
|
|
18
|
+
* - Delayed TTS reminders if user doesn't respond
|
|
19
|
+
* - Follow-up reminders with exponential backoff
|
|
20
|
+
* - Monitor wake and volume boost
|
|
21
|
+
* - Cross-platform support (Windows, macOS, Linux)
|
|
22
|
+
*
|
|
23
|
+
* @type {import("@opencode-ai/plugin").Plugin}
|
|
24
|
+
*/
|
|
25
|
+
export default async function SmartVoiceNotifyPlugin({ project, client, $, directory, worktree }) {
|
|
26
|
+
const config = getTTSConfig();
|
|
27
|
+
|
|
28
|
+
// Master switch: if plugin is disabled, return empty handlers immediately
|
|
29
|
+
if (config.enabled === false) {
|
|
30
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
31
|
+
const logsDir = path.join(configDir, 'logs');
|
|
32
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
33
|
+
if (config.debugLog) {
|
|
34
|
+
try {
|
|
35
|
+
if (!fs.existsSync(logsDir)) {
|
|
36
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
const timestamp = new Date().toISOString();
|
|
39
|
+
fs.appendFileSync(logFile, `[${timestamp}] Plugin disabled via config (enabled: false) - no event handlers registered\n`);
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tts = createTTS({ $, client });
|
|
46
|
+
|
|
47
|
+
const platform = os.platform();
|
|
48
|
+
|
|
49
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
50
|
+
const logsDir = path.join(configDir, 'logs');
|
|
51
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
52
|
+
|
|
53
|
+
// Ensure logs directory exists if debug logging is enabled
|
|
54
|
+
if (config.debugLog && !fs.existsSync(logsDir)) {
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Silently fail - logging is optional
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Track pending TTS reminders (can be cancelled if user responds)
|
|
63
|
+
const pendingReminders = new Map();
|
|
64
|
+
|
|
65
|
+
// Track last user activity time
|
|
66
|
+
let lastUserActivityTime = Date.now();
|
|
67
|
+
|
|
68
|
+
// Track seen user message IDs to avoid treating message UPDATES as new user activity
|
|
69
|
+
// Key insight: message.updated fires for EVERY modification to a message, not just new messages
|
|
70
|
+
// We only want to treat the FIRST occurrence of each user message as "user activity"
|
|
71
|
+
const seenUserMessageIds = new Set();
|
|
72
|
+
|
|
73
|
+
// Track the timestamp of when session went idle, to detect post-idle user messages
|
|
74
|
+
let lastSessionIdleTime = 0;
|
|
75
|
+
|
|
76
|
+
// Track active permission request to prevent race condition where user responds
|
|
77
|
+
// before async notification code runs. Set on permission.updated, cleared on permission.replied.
|
|
78
|
+
let activePermissionId = null;
|
|
79
|
+
|
|
80
|
+
// ========================================
|
|
81
|
+
// PERMISSION BATCHING STATE
|
|
82
|
+
// Batches multiple simultaneous permission requests into a single notification
|
|
83
|
+
// ========================================
|
|
84
|
+
|
|
85
|
+
// Array of permission IDs waiting to be notified (collected during batch window)
|
|
86
|
+
let pendingPermissionBatch = [];
|
|
87
|
+
|
|
88
|
+
// Timeout ID for the batch window (debounce timer)
|
|
89
|
+
let permissionBatchTimeout = null;
|
|
90
|
+
|
|
91
|
+
// Batch window duration in milliseconds (how long to wait for more permissions)
|
|
92
|
+
const PERMISSION_BATCH_WINDOW_MS = config.permissionBatchWindowMs || 800;
|
|
93
|
+
|
|
94
|
+
// ========================================
|
|
95
|
+
// QUESTION BATCHING STATE (SDK v1.1.7+)
|
|
96
|
+
// Batches multiple simultaneous question requests into a single notification
|
|
97
|
+
// ========================================
|
|
98
|
+
|
|
99
|
+
// Array of question request objects waiting to be notified (collected during batch window)
|
|
100
|
+
// Each object contains { id: string, questionCount: number } to track actual question count
|
|
101
|
+
let pendingQuestionBatch = [];
|
|
102
|
+
|
|
103
|
+
// Timeout ID for the question batch window (debounce timer)
|
|
104
|
+
let questionBatchTimeout = null;
|
|
105
|
+
|
|
106
|
+
// Batch window duration in milliseconds (how long to wait for more questions)
|
|
107
|
+
const QUESTION_BATCH_WINDOW_MS = config.questionBatchWindowMs || 800;
|
|
108
|
+
|
|
109
|
+
// Track active question request to prevent race condition where user responds
|
|
110
|
+
// before async notification code runs. Set on question.asked, cleared on question.replied/rejected.
|
|
111
|
+
let activeQuestionId = null;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write debug message to log file
|
|
115
|
+
*/
|
|
116
|
+
const debugLog = (message) => {
|
|
117
|
+
if (!config.debugLog) return;
|
|
118
|
+
try {
|
|
119
|
+
const timestamp = new Date().toISOString();
|
|
120
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
|
121
|
+
} catch (e) {}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get a random message from an array of messages
|
|
126
|
+
*/
|
|
127
|
+
const getRandomMessage = (messages) => {
|
|
128
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
129
|
+
return 'Notification';
|
|
130
|
+
}
|
|
131
|
+
return messages[Math.floor(Math.random() * messages.length)];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Show a TUI toast notification
|
|
136
|
+
*/
|
|
137
|
+
const showToast = async (message, variant = 'info', duration = 5000) => {
|
|
138
|
+
if (!config.enableToast) return;
|
|
139
|
+
try {
|
|
140
|
+
if (typeof client?.tui?.showToast === 'function') {
|
|
141
|
+
await client.tui.showToast({
|
|
142
|
+
body: {
|
|
143
|
+
message: message,
|
|
144
|
+
variant: variant,
|
|
145
|
+
duration: duration
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Play a sound file from assets
|
|
154
|
+
*/
|
|
155
|
+
const playSound = async (soundFile, loops = 1) => {
|
|
156
|
+
if (!config.enableSound) return;
|
|
157
|
+
try {
|
|
158
|
+
const soundPath = path.isAbsolute(soundFile)
|
|
159
|
+
? soundFile
|
|
160
|
+
: path.join(configDir, soundFile);
|
|
161
|
+
|
|
162
|
+
if (!fs.existsSync(soundPath)) {
|
|
163
|
+
debugLog(`playSound: file not found: ${soundPath}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await tts.wakeMonitor();
|
|
168
|
+
await tts.forceVolume();
|
|
169
|
+
await tts.playAudioFile(soundPath, loops);
|
|
170
|
+
debugLog(`playSound: played ${soundPath} (${loops}x)`);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
debugLog(`playSound error: ${e.message}`);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Cancel any pending TTS reminder for a given type
|
|
178
|
+
*/
|
|
179
|
+
const cancelPendingReminder = (type) => {
|
|
180
|
+
const existing = pendingReminders.get(type);
|
|
181
|
+
if (existing) {
|
|
182
|
+
clearTimeout(existing.timeoutId);
|
|
183
|
+
pendingReminders.delete(type);
|
|
184
|
+
debugLog(`cancelPendingReminder: cancelled ${type}`);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Cancel all pending TTS reminders (called on user activity)
|
|
190
|
+
*/
|
|
191
|
+
const cancelAllPendingReminders = () => {
|
|
192
|
+
for (const [type, reminder] of pendingReminders.entries()) {
|
|
193
|
+
clearTimeout(reminder.timeoutId);
|
|
194
|
+
debugLog(`cancelAllPendingReminders: cancelled ${type}`);
|
|
195
|
+
}
|
|
196
|
+
pendingReminders.clear();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Schedule a TTS reminder if user doesn't respond within configured delay.
|
|
201
|
+
* The reminder uses a personalized TTS message.
|
|
202
|
+
* @param {string} type - 'idle', 'permission', or 'question'
|
|
203
|
+
* @param {string} message - The TTS message to speak (used directly, supports count-aware messages)
|
|
204
|
+
* @param {object} options - Additional options (fallbackSound, permissionCount, questionCount)
|
|
205
|
+
*/
|
|
206
|
+
const scheduleTTSReminder = (type, message, options = {}) => {
|
|
207
|
+
// Check if TTS reminders are enabled
|
|
208
|
+
if (!config.enableTTSReminder) {
|
|
209
|
+
debugLog(`scheduleTTSReminder: TTS reminders disabled`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Get delay from config (in seconds, convert to ms)
|
|
214
|
+
let delaySeconds;
|
|
215
|
+
if (type === 'permission') {
|
|
216
|
+
delaySeconds = config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
|
|
217
|
+
} else if (type === 'question') {
|
|
218
|
+
delaySeconds = config.questionReminderDelaySeconds || config.ttsReminderDelaySeconds || 25;
|
|
219
|
+
} else {
|
|
220
|
+
delaySeconds = config.idleReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
|
|
221
|
+
}
|
|
222
|
+
const delayMs = delaySeconds * 1000;
|
|
223
|
+
|
|
224
|
+
// Cancel any existing reminder of this type
|
|
225
|
+
cancelPendingReminder(type);
|
|
226
|
+
|
|
227
|
+
// Store count for generating count-aware messages in reminders
|
|
228
|
+
const itemCount = options.permissionCount || options.questionCount || 1;
|
|
229
|
+
|
|
230
|
+
debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s (count=${itemCount})`);
|
|
231
|
+
|
|
232
|
+
const timeoutId = setTimeout(async () => {
|
|
233
|
+
try {
|
|
234
|
+
// Check if reminder was cancelled (user responded)
|
|
235
|
+
if (!pendingReminders.has(type)) {
|
|
236
|
+
debugLog(`scheduleTTSReminder: ${type} was cancelled before firing`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check if user has been active since notification
|
|
241
|
+
const reminder = pendingReminders.get(type);
|
|
242
|
+
if (reminder && lastUserActivityTime > reminder.scheduledAt) {
|
|
243
|
+
debugLog(`scheduleTTSReminder: ${type} skipped - user active since notification`);
|
|
244
|
+
pendingReminders.delete(type);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
debugLog(`scheduleTTSReminder: firing ${type} TTS reminder (count=${reminder?.itemCount || 1})`);
|
|
249
|
+
|
|
250
|
+
// Get the appropriate reminder message
|
|
251
|
+
// For permissions/questions with count > 1, use the count-aware message generator
|
|
252
|
+
const storedCount = reminder?.itemCount || 1;
|
|
253
|
+
let reminderMessage;
|
|
254
|
+
if (type === 'permission') {
|
|
255
|
+
reminderMessage = await getPermissionMessage(storedCount, true);
|
|
256
|
+
} else if (type === 'question') {
|
|
257
|
+
reminderMessage = await getQuestionMessage(storedCount, true);
|
|
258
|
+
} else {
|
|
259
|
+
reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check for ElevenLabs API key configuration issues
|
|
263
|
+
// If user hasn't responded (reminder firing) and config is missing, warn about fallback
|
|
264
|
+
if (config.ttsEngine === 'elevenlabs' && (!config.elevenLabsApiKey || config.elevenLabsApiKey.trim() === '')) {
|
|
265
|
+
debugLog('ElevenLabs API key missing during reminder - showing fallback toast');
|
|
266
|
+
await showToast("⚠️ ElevenLabs API Key missing! Falling back to Edge TTS.", "warning", 6000);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Speak the reminder using TTS
|
|
270
|
+
await tts.wakeMonitor();
|
|
271
|
+
await tts.forceVolume();
|
|
272
|
+
await tts.speak(reminderMessage, {
|
|
273
|
+
enableTTS: true,
|
|
274
|
+
fallbackSound: options.fallbackSound
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// CRITICAL FIX: Check if cancelled during playback (user responded while TTS was speaking)
|
|
278
|
+
if (!pendingReminders.has(type)) {
|
|
279
|
+
debugLog(`scheduleTTSReminder: ${type} cancelled during playback - aborting follow-up`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Clean up
|
|
284
|
+
pendingReminders.delete(type);
|
|
285
|
+
|
|
286
|
+
// Schedule follow-up reminder if configured (exponential backoff or fixed)
|
|
287
|
+
if (config.enableFollowUpReminders) {
|
|
288
|
+
const followUpCount = (reminder?.followUpCount || 0) + 1;
|
|
289
|
+
const maxFollowUps = config.maxFollowUpReminders || 3;
|
|
290
|
+
|
|
291
|
+
if (followUpCount < maxFollowUps) {
|
|
292
|
+
// Schedule another reminder with optional backoff
|
|
293
|
+
const backoffMultiplier = config.reminderBackoffMultiplier || 1.5;
|
|
294
|
+
const nextDelay = delaySeconds * Math.pow(backoffMultiplier, followUpCount);
|
|
295
|
+
|
|
296
|
+
debugLog(`scheduleTTSReminder: scheduling follow-up ${followUpCount + 1}/${maxFollowUps} in ${nextDelay}s`);
|
|
297
|
+
|
|
298
|
+
const followUpTimeoutId = setTimeout(async () => {
|
|
299
|
+
const followUpReminder = pendingReminders.get(type);
|
|
300
|
+
if (!followUpReminder || lastUserActivityTime > followUpReminder.scheduledAt) {
|
|
301
|
+
pendingReminders.delete(type);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Use count-aware message for follow-ups too
|
|
306
|
+
const followUpStoredCount = followUpReminder?.itemCount || 1;
|
|
307
|
+
let followUpMessage;
|
|
308
|
+
if (type === 'permission') {
|
|
309
|
+
followUpMessage = await getPermissionMessage(followUpStoredCount, true);
|
|
310
|
+
} else if (type === 'question') {
|
|
311
|
+
followUpMessage = await getQuestionMessage(followUpStoredCount, true);
|
|
312
|
+
} else {
|
|
313
|
+
followUpMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await tts.wakeMonitor();
|
|
317
|
+
await tts.forceVolume();
|
|
318
|
+
await tts.speak(followUpMessage, {
|
|
319
|
+
enableTTS: true,
|
|
320
|
+
fallbackSound: options.fallbackSound
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
pendingReminders.delete(type);
|
|
324
|
+
}, nextDelay * 1000);
|
|
325
|
+
|
|
326
|
+
pendingReminders.set(type, {
|
|
327
|
+
timeoutId: followUpTimeoutId,
|
|
328
|
+
scheduledAt: Date.now(),
|
|
329
|
+
followUpCount,
|
|
330
|
+
itemCount: storedCount // Preserve the count for follow-ups
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch (e) {
|
|
335
|
+
debugLog(`scheduleTTSReminder error: ${e.message}`);
|
|
336
|
+
pendingReminders.delete(type);
|
|
337
|
+
}
|
|
338
|
+
}, delayMs);
|
|
339
|
+
|
|
340
|
+
// Store the pending reminder with item count
|
|
341
|
+
pendingReminders.set(type, {
|
|
342
|
+
timeoutId,
|
|
343
|
+
scheduledAt: Date.now(),
|
|
344
|
+
followUpCount: 0,
|
|
345
|
+
itemCount // Store count for later use
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Smart notification: play sound first, then schedule TTS reminder
|
|
351
|
+
* @param {string} type - 'idle', 'permission', or 'question'
|
|
352
|
+
* @param {object} options - Notification options
|
|
353
|
+
*/
|
|
354
|
+
const smartNotify = async (type, options = {}) => {
|
|
355
|
+
const {
|
|
356
|
+
soundFile,
|
|
357
|
+
soundLoops = 1,
|
|
358
|
+
ttsMessage,
|
|
359
|
+
fallbackSound,
|
|
360
|
+
permissionCount, // Support permission count for batched notifications
|
|
361
|
+
questionCount // Support question count for batched notifications
|
|
362
|
+
} = options;
|
|
363
|
+
|
|
364
|
+
// Step 1: Play the immediate sound notification
|
|
365
|
+
if (soundFile) {
|
|
366
|
+
await playSound(soundFile, soundLoops);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// CRITICAL FIX: Check if user responded during sound playback
|
|
370
|
+
// For idle notifications: check if there was new activity after the idle start
|
|
371
|
+
if (type === 'idle' && lastUserActivityTime > lastSessionIdleTime) {
|
|
372
|
+
debugLog(`smartNotify: user active during sound - aborting idle reminder`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// For permission notifications: check if the permission was already handled
|
|
376
|
+
if (type === 'permission' && !activePermissionId) {
|
|
377
|
+
debugLog(`smartNotify: permission handled during sound - aborting reminder`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// For question notifications: check if the question was already answered/rejected
|
|
381
|
+
if (type === 'question' && !activeQuestionId) {
|
|
382
|
+
debugLog(`smartNotify: question handled during sound - aborting reminder`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Step 2: Schedule TTS reminder if user doesn't respond
|
|
387
|
+
if (config.enableTTSReminder && ttsMessage) {
|
|
388
|
+
scheduleTTSReminder(type, ttsMessage, { fallbackSound, permissionCount, questionCount });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Step 3: If TTS-first mode is enabled, also speak immediately
|
|
392
|
+
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
393
|
+
let immediateMessage;
|
|
394
|
+
if (type === 'permission') {
|
|
395
|
+
immediateMessage = await getSmartMessage('permission', false, config.permissionTTSMessages);
|
|
396
|
+
} else if (type === 'question') {
|
|
397
|
+
immediateMessage = await getSmartMessage('question', false, config.questionTTSMessages);
|
|
398
|
+
} else {
|
|
399
|
+
immediateMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await tts.speak(immediateMessage, {
|
|
403
|
+
enableTTS: true,
|
|
404
|
+
fallbackSound
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Get a count-aware TTS message for permission requests
|
|
411
|
+
* Uses AI generation when enabled, falls back to static messages
|
|
412
|
+
* @param {number} count - Number of permission requests
|
|
413
|
+
* @param {boolean} isReminder - Whether this is a reminder message
|
|
414
|
+
* @returns {Promise<string>} The formatted message
|
|
415
|
+
*/
|
|
416
|
+
const getPermissionMessage = async (count, isReminder = false) => {
|
|
417
|
+
const messages = isReminder
|
|
418
|
+
? config.permissionReminderTTSMessages
|
|
419
|
+
: config.permissionTTSMessages;
|
|
420
|
+
|
|
421
|
+
// If AI messages are enabled, ALWAYS try AI first (regardless of count)
|
|
422
|
+
if (config.enableAIMessages) {
|
|
423
|
+
const aiMessage = await getSmartMessage('permission', isReminder, messages, { count, type: 'permission' });
|
|
424
|
+
// getSmartMessage returns static message as fallback, so if AI was attempted
|
|
425
|
+
// and succeeded, we'll get the AI message. If it failed, we get static.
|
|
426
|
+
// Check if we got a valid message (not the generic fallback)
|
|
427
|
+
if (aiMessage && aiMessage !== 'Notification') {
|
|
428
|
+
return aiMessage;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Fallback to static messages (AI disabled or failed with generic fallback)
|
|
433
|
+
if (count === 1) {
|
|
434
|
+
return getRandomMessage(messages);
|
|
435
|
+
} else {
|
|
436
|
+
const countMessages = isReminder
|
|
437
|
+
? config.permissionReminderTTSMessagesMultiple
|
|
438
|
+
: config.permissionTTSMessagesMultiple;
|
|
439
|
+
|
|
440
|
+
if (countMessages && countMessages.length > 0) {
|
|
441
|
+
const template = getRandomMessage(countMessages);
|
|
442
|
+
return template.replace('{count}', count.toString());
|
|
443
|
+
}
|
|
444
|
+
return `Attention! There are ${count} permission requests waiting for your approval.`;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Get a count-aware TTS message for question requests (SDK v1.1.7+)
|
|
450
|
+
* Uses AI generation when enabled, falls back to static messages
|
|
451
|
+
* @param {number} count - Number of question requests
|
|
452
|
+
* @param {boolean} isReminder - Whether this is a reminder message
|
|
453
|
+
* @returns {Promise<string>} The formatted message
|
|
454
|
+
*/
|
|
455
|
+
const getQuestionMessage = async (count, isReminder = false) => {
|
|
456
|
+
const messages = isReminder
|
|
457
|
+
? config.questionReminderTTSMessages
|
|
458
|
+
: config.questionTTSMessages;
|
|
459
|
+
|
|
460
|
+
// If AI messages are enabled, ALWAYS try AI first (regardless of count)
|
|
461
|
+
if (config.enableAIMessages) {
|
|
462
|
+
const aiMessage = await getSmartMessage('question', isReminder, messages, { count, type: 'question' });
|
|
463
|
+
// getSmartMessage returns static message as fallback, so if AI was attempted
|
|
464
|
+
// and succeeded, we'll get the AI message. If it failed, we get static.
|
|
465
|
+
// Check if we got a valid message (not the generic fallback)
|
|
466
|
+
if (aiMessage && aiMessage !== 'Notification') {
|
|
467
|
+
return aiMessage;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Fallback to static messages (AI disabled or failed with generic fallback)
|
|
472
|
+
if (count === 1) {
|
|
473
|
+
return getRandomMessage(messages);
|
|
474
|
+
} else {
|
|
475
|
+
const countMessages = isReminder
|
|
476
|
+
? config.questionReminderTTSMessagesMultiple
|
|
477
|
+
: config.questionTTSMessagesMultiple;
|
|
478
|
+
|
|
479
|
+
if (countMessages && countMessages.length > 0) {
|
|
480
|
+
const template = getRandomMessage(countMessages);
|
|
481
|
+
return template.replace('{count}', count.toString());
|
|
482
|
+
}
|
|
483
|
+
return `Hey! I have ${count} questions for you. Please check your screen.`;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Process the batched permission requests as a single notification
|
|
489
|
+
* Called after the batch window expires
|
|
490
|
+
*
|
|
491
|
+
* FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
|
|
492
|
+
* AI message generation can take 3-15+ seconds, which was delaying sound playback.
|
|
493
|
+
*/
|
|
494
|
+
const processPermissionBatch = async () => {
|
|
495
|
+
// Capture and clear the batch
|
|
496
|
+
const batch = [...pendingPermissionBatch];
|
|
497
|
+
const batchCount = batch.length;
|
|
498
|
+
pendingPermissionBatch = [];
|
|
499
|
+
permissionBatchTimeout = null;
|
|
500
|
+
|
|
501
|
+
if (batchCount === 0) {
|
|
502
|
+
debugLog('processPermissionBatch: empty batch, skipping');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
debugLog(`processPermissionBatch: processing ${batchCount} permission(s)`);
|
|
507
|
+
|
|
508
|
+
// Set activePermissionId to the first one (for race condition checks)
|
|
509
|
+
// We track all IDs in the batch for proper cleanup
|
|
510
|
+
activePermissionId = batch[0];
|
|
511
|
+
|
|
512
|
+
// Step 1: Show toast IMMEDIATELY (fire and forget - no await)
|
|
513
|
+
const toastMessage = batchCount === 1
|
|
514
|
+
? "⚠️ Permission request requires your attention"
|
|
515
|
+
: `⚠️ ${batchCount} permission requests require your attention`;
|
|
516
|
+
showToast(toastMessage, "warning", 8000); // No await - instant display
|
|
517
|
+
|
|
518
|
+
// Step 2: Play sound (after toast is triggered)
|
|
519
|
+
const soundLoops = batchCount === 1 ? 2 : Math.min(3, batchCount);
|
|
520
|
+
await playSound(config.permissionSound, soundLoops);
|
|
521
|
+
|
|
522
|
+
// CHECK: Did user already respond while sound was playing?
|
|
523
|
+
if (pendingPermissionBatch.length > 0) {
|
|
524
|
+
// New permissions arrived during sound - they'll be handled in next batch
|
|
525
|
+
debugLog('processPermissionBatch: new permissions arrived during sound');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Step 3: Check race condition - did user respond during sound?
|
|
529
|
+
if (activePermissionId === null) {
|
|
530
|
+
debugLog('processPermissionBatch: user responded during sound - aborting');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Step 4: Generate AI message for reminder AFTER sound played
|
|
535
|
+
const reminderMessage = await getPermissionMessage(batchCount, true);
|
|
536
|
+
|
|
537
|
+
// Step 5: Schedule TTS reminder if enabled
|
|
538
|
+
if (config.enableTTSReminder && reminderMessage) {
|
|
539
|
+
scheduleTTSReminder('permission', reminderMessage, {
|
|
540
|
+
fallbackSound: config.permissionSound,
|
|
541
|
+
permissionCount: batchCount
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Step 6: If TTS-first or both mode, generate and speak immediate message
|
|
546
|
+
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
547
|
+
const ttsMessage = await getPermissionMessage(batchCount, false);
|
|
548
|
+
await tts.wakeMonitor();
|
|
549
|
+
await tts.forceVolume();
|
|
550
|
+
await tts.speak(ttsMessage, {
|
|
551
|
+
enableTTS: true,
|
|
552
|
+
fallbackSound: config.permissionSound
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Final check: if user responded during notification, cancel scheduled reminder
|
|
557
|
+
if (activePermissionId === null) {
|
|
558
|
+
debugLog('processPermissionBatch: user responded during notification - cancelling reminder');
|
|
559
|
+
cancelPendingReminder('permission');
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Process the batched question requests as a single notification (SDK v1.1.7+)
|
|
565
|
+
* Called after the batch window expires
|
|
566
|
+
*
|
|
567
|
+
* FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
|
|
568
|
+
* AI message generation can take 3-15+ seconds, which was delaying sound playback.
|
|
569
|
+
*/
|
|
570
|
+
const processQuestionBatch = async () => {
|
|
571
|
+
// Capture and clear the batch
|
|
572
|
+
const batch = [...pendingQuestionBatch];
|
|
573
|
+
pendingQuestionBatch = [];
|
|
574
|
+
questionBatchTimeout = null;
|
|
575
|
+
|
|
576
|
+
if (batch.length === 0) {
|
|
577
|
+
debugLog('processQuestionBatch: empty batch, skipping');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Calculate total number of questions across all batched requests
|
|
582
|
+
// Each batch item is { id, questionCount } where questionCount is the number of questions in that request
|
|
583
|
+
const totalQuestionCount = batch.reduce((sum, item) => sum + (item.questionCount || 1), 0);
|
|
584
|
+
|
|
585
|
+
debugLog(`processQuestionBatch: processing ${batch.length} request(s) with ${totalQuestionCount} total question(s)`);
|
|
586
|
+
|
|
587
|
+
// Set activeQuestionId to the first one (for race condition checks)
|
|
588
|
+
// We track all IDs in the batch for proper cleanup
|
|
589
|
+
activeQuestionId = batch[0]?.id;
|
|
590
|
+
|
|
591
|
+
// Step 1: Show toast IMMEDIATELY (fire and forget - no await)
|
|
592
|
+
const toastMessage = totalQuestionCount === 1
|
|
593
|
+
? "❓ The agent has a question for you"
|
|
594
|
+
: `❓ The agent has ${totalQuestionCount} questions for you`;
|
|
595
|
+
showToast(toastMessage, "info", 8000); // No await - instant display
|
|
596
|
+
|
|
597
|
+
// Step 2: Play sound (after toast is triggered)
|
|
598
|
+
await playSound(config.questionSound, 2);
|
|
599
|
+
|
|
600
|
+
// CHECK: Did user already respond while sound was playing?
|
|
601
|
+
if (pendingQuestionBatch.length > 0) {
|
|
602
|
+
// New questions arrived during sound - they'll be handled in next batch
|
|
603
|
+
debugLog('processQuestionBatch: new questions arrived during sound');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Step 3: Check race condition - did user respond during sound?
|
|
607
|
+
if (activeQuestionId === null) {
|
|
608
|
+
debugLog('processQuestionBatch: user responded during sound - aborting');
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Step 4: Generate AI message for reminder AFTER sound played
|
|
613
|
+
const reminderMessage = await getQuestionMessage(totalQuestionCount, true);
|
|
614
|
+
|
|
615
|
+
// Step 5: Schedule TTS reminder if enabled
|
|
616
|
+
if (config.enableTTSReminder && reminderMessage) {
|
|
617
|
+
scheduleTTSReminder('question', reminderMessage, {
|
|
618
|
+
fallbackSound: config.questionSound,
|
|
619
|
+
questionCount: totalQuestionCount
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Step 6: If TTS-first or both mode, generate and speak immediate message
|
|
624
|
+
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
625
|
+
const ttsMessage = await getQuestionMessage(totalQuestionCount, false);
|
|
626
|
+
await tts.wakeMonitor();
|
|
627
|
+
await tts.forceVolume();
|
|
628
|
+
await tts.speak(ttsMessage, {
|
|
629
|
+
enableTTS: true,
|
|
630
|
+
fallbackSound: config.questionSound
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Final check: if user responded during notification, cancel scheduled reminder
|
|
635
|
+
if (activeQuestionId === null) {
|
|
636
|
+
debugLog('processQuestionBatch: user responded during notification - cancelling reminder');
|
|
637
|
+
cancelPendingReminder('question');
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
event: async ({ event }) => {
|
|
643
|
+
try {
|
|
644
|
+
// ========================================
|
|
645
|
+
// USER ACTIVITY DETECTION
|
|
646
|
+
// Cancels pending TTS reminders when user responds
|
|
647
|
+
// ========================================
|
|
648
|
+
// NOTE: OpenCode event types (supporting SDK v1.0.x, v1.1.x, and v1.1.7+):
|
|
649
|
+
// - message.updated: fires when a message is added/updated (use properties.info.role to check user vs assistant)
|
|
650
|
+
// - permission.updated (SDK v1.0.x): fires when a permission request is created
|
|
651
|
+
// - permission.asked (SDK v1.1.1+): fires when a permission request is created (replaces permission.updated)
|
|
652
|
+
// - permission.replied: fires when user responds to a permission request
|
|
653
|
+
// - SDK v1.0.x: uses permissionID, response
|
|
654
|
+
// - SDK v1.1.1+: uses requestID, reply
|
|
655
|
+
// - question.asked (SDK v1.1.7+): fires when agent asks user a question
|
|
656
|
+
// - question.replied (SDK v1.1.7+): fires when user answers a question
|
|
657
|
+
// - question.rejected (SDK v1.1.7+): fires when user dismisses a question
|
|
658
|
+
// - session.created: fires when a new session starts
|
|
659
|
+
//
|
|
660
|
+
// CRITICAL: message.updated fires for EVERY modification to a message (not just creation).
|
|
661
|
+
// Context-injector and other plugins can trigger multiple updates for the same message.
|
|
662
|
+
// We must only treat NEW user messages (after session.idle) as actual user activity.
|
|
663
|
+
|
|
664
|
+
if (event.type === "message.updated") {
|
|
665
|
+
const messageInfo = event.properties?.info;
|
|
666
|
+
const messageId = messageInfo?.id;
|
|
667
|
+
const isUserMessage = messageInfo?.role === 'user';
|
|
668
|
+
|
|
669
|
+
if (isUserMessage && messageId) {
|
|
670
|
+
// Check if this is a NEW user message we haven't seen before
|
|
671
|
+
const isNewMessage = !seenUserMessageIds.has(messageId);
|
|
672
|
+
|
|
673
|
+
// Check if this message arrived AFTER the last session.idle
|
|
674
|
+
// This is the key: only a message sent AFTER idle indicates user responded
|
|
675
|
+
const messageTime = messageInfo?.time?.created;
|
|
676
|
+
const isAfterIdle = lastSessionIdleTime > 0 && messageTime && (messageTime * 1000) > lastSessionIdleTime;
|
|
677
|
+
|
|
678
|
+
if (isNewMessage) {
|
|
679
|
+
seenUserMessageIds.add(messageId);
|
|
680
|
+
|
|
681
|
+
// Only cancel reminders if this is a NEW message AFTER session went idle
|
|
682
|
+
// OR if there are no pending reminders (initial message before any notifications)
|
|
683
|
+
if (isAfterIdle || pendingReminders.size === 0) {
|
|
684
|
+
if (isAfterIdle) {
|
|
685
|
+
lastUserActivityTime = Date.now();
|
|
686
|
+
cancelAllPendingReminders();
|
|
687
|
+
debugLog(`NEW user message AFTER idle: ${messageId} - cancelled pending reminders`);
|
|
688
|
+
} else {
|
|
689
|
+
debugLog(`Initial user message (before any idle): ${messageId} - no reminders to cancel`);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
debugLog(`Ignored: user message ${messageId} created BEFORE session.idle (time=${messageTime}, idleTime=${lastSessionIdleTime})`);
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
// This is an UPDATE to an existing message (e.g., context injection)
|
|
696
|
+
debugLog(`Ignored: update to existing user message ${messageId} (not new activity)`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (event.type === "permission.replied") {
|
|
702
|
+
// User responded to a permission request (granted or denied)
|
|
703
|
+
// Structure varies by SDK version:
|
|
704
|
+
// - Old SDK: event.properties.{ sessionID, permissionID, response }
|
|
705
|
+
// - New SDK (v1.1.1+): event.properties.{ sessionID, requestID, reply }
|
|
706
|
+
// CRITICAL: Clear activePermissionId FIRST to prevent race condition
|
|
707
|
+
// where permission.updated/asked handler is still running async operations
|
|
708
|
+
const repliedPermissionId = event.properties?.permissionID || event.properties?.requestID;
|
|
709
|
+
const response = event.properties?.response || event.properties?.reply;
|
|
710
|
+
|
|
711
|
+
// Remove this permission from the pending batch (if still waiting)
|
|
712
|
+
if (repliedPermissionId && pendingPermissionBatch.includes(repliedPermissionId)) {
|
|
713
|
+
pendingPermissionBatch = pendingPermissionBatch.filter(id => id !== repliedPermissionId);
|
|
714
|
+
debugLog(`Permission replied: removed ${repliedPermissionId} from pending batch (${pendingPermissionBatch.length} remaining)`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// If batch is now empty and we have a pending batch timeout, we can cancel it
|
|
718
|
+
// (user responded to all permissions before batch window expired)
|
|
719
|
+
if (pendingPermissionBatch.length === 0 && permissionBatchTimeout) {
|
|
720
|
+
clearTimeout(permissionBatchTimeout);
|
|
721
|
+
permissionBatchTimeout = null;
|
|
722
|
+
debugLog('Permission replied: cancelled batch timeout (all permissions handled)');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Match if IDs are equal, or if we have an active permission with unknown ID (undefined)
|
|
726
|
+
// (This happens if permission.updated/asked received an event without permissionID)
|
|
727
|
+
if (activePermissionId === repliedPermissionId || activePermissionId === undefined) {
|
|
728
|
+
activePermissionId = null;
|
|
729
|
+
debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId || '(unknown)'}`);
|
|
730
|
+
}
|
|
731
|
+
lastUserActivityTime = Date.now();
|
|
732
|
+
cancelPendingReminder('permission'); // Cancel permission-specific reminder
|
|
733
|
+
debugLog(`Permission replied: ${event.type} (response=${response}) - cancelled permission reminder`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (event.type === "session.created") {
|
|
737
|
+
// New session started - reset tracking state
|
|
738
|
+
lastUserActivityTime = Date.now();
|
|
739
|
+
lastSessionIdleTime = 0;
|
|
740
|
+
activePermissionId = null;
|
|
741
|
+
activeQuestionId = null;
|
|
742
|
+
seenUserMessageIds.clear();
|
|
743
|
+
cancelAllPendingReminders();
|
|
744
|
+
|
|
745
|
+
// Reset permission batch state
|
|
746
|
+
pendingPermissionBatch = [];
|
|
747
|
+
if (permissionBatchTimeout) {
|
|
748
|
+
clearTimeout(permissionBatchTimeout);
|
|
749
|
+
permissionBatchTimeout = null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Reset question batch state
|
|
753
|
+
pendingQuestionBatch = [];
|
|
754
|
+
if (questionBatchTimeout) {
|
|
755
|
+
clearTimeout(questionBatchTimeout);
|
|
756
|
+
questionBatchTimeout = null;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
debugLog(`Session created: ${event.type} - reset all tracking state`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ========================================
|
|
763
|
+
// NOTIFICATION 1: Session Idle (Agent Finished)
|
|
764
|
+
//
|
|
765
|
+
// FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
|
|
766
|
+
// AI message generation can take 3-15+ seconds, which was delaying sound playback.
|
|
767
|
+
// ========================================
|
|
768
|
+
if (event.type === "session.idle") {
|
|
769
|
+
const sessionID = event.properties?.sessionID;
|
|
770
|
+
if (!sessionID) return;
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
const session = await client.session.get({ path: { id: sessionID } });
|
|
774
|
+
if (session?.data?.parentID) {
|
|
775
|
+
debugLog(`session.idle: skipped (sub-session ${sessionID})`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
} catch (e) {}
|
|
779
|
+
|
|
780
|
+
// Record the time session went idle - used to filter out pre-idle messages
|
|
781
|
+
lastSessionIdleTime = Date.now();
|
|
782
|
+
|
|
783
|
+
debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
|
|
784
|
+
|
|
785
|
+
// Step 1: Show toast IMMEDIATELY (fire and forget - no await)
|
|
786
|
+
showToast("✅ Agent has finished working", "success", 5000); // No await - instant display
|
|
787
|
+
|
|
788
|
+
// Step 2: Play sound (after toast is triggered)
|
|
789
|
+
// Only play sound in sound-first, sound-only, or both mode
|
|
790
|
+
if (config.notificationMode !== 'tts-first') {
|
|
791
|
+
await playSound(config.idleSound, 1);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Step 3: Check race condition - did user respond during sound?
|
|
795
|
+
if (lastUserActivityTime > lastSessionIdleTime) {
|
|
796
|
+
debugLog(`session.idle: user active during sound - aborting`);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Step 4: Generate AI message for reminder AFTER sound played
|
|
801
|
+
const reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
|
|
802
|
+
|
|
803
|
+
// Step 5: Schedule TTS reminder if enabled
|
|
804
|
+
if (config.enableTTSReminder && reminderMessage) {
|
|
805
|
+
scheduleTTSReminder('idle', reminderMessage, {
|
|
806
|
+
fallbackSound: config.idleSound
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Step 6: If TTS-first or both mode, generate and speak immediate message
|
|
811
|
+
if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
|
|
812
|
+
const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
|
|
813
|
+
await tts.wakeMonitor();
|
|
814
|
+
await tts.forceVolume();
|
|
815
|
+
await tts.speak(ttsMessage, {
|
|
816
|
+
enableTTS: true,
|
|
817
|
+
fallbackSound: config.idleSound
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ========================================
|
|
823
|
+
// NOTIFICATION 2: Permission Request (BATCHED)
|
|
824
|
+
// ========================================
|
|
825
|
+
// NOTE: OpenCode SDK v1.1.1+ changed permission events:
|
|
826
|
+
// - Old: "permission.updated" with properties.id
|
|
827
|
+
// - New: "permission.asked" with properties.id
|
|
828
|
+
// We support both for backward compatibility.
|
|
829
|
+
//
|
|
830
|
+
// BATCHING: When multiple permissions arrive simultaneously (e.g., 5 at once),
|
|
831
|
+
// we batch them into a single notification instead of playing 5 overlapping sounds.
|
|
832
|
+
if (event.type === "permission.updated" || event.type === "permission.asked") {
|
|
833
|
+
// Capture permissionID
|
|
834
|
+
const permissionId = event.properties?.id;
|
|
835
|
+
|
|
836
|
+
if (!permissionId) {
|
|
837
|
+
debugLog(`${event.type}: permission ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Add to the pending batch (avoid duplicates)
|
|
841
|
+
if (permissionId && !pendingPermissionBatch.includes(permissionId)) {
|
|
842
|
+
pendingPermissionBatch.push(permissionId);
|
|
843
|
+
debugLog(`${event.type}: added ${permissionId} to batch (now ${pendingPermissionBatch.length} pending)`);
|
|
844
|
+
} else if (!permissionId) {
|
|
845
|
+
// If no ID, still count it (use a placeholder)
|
|
846
|
+
pendingPermissionBatch.push(`unknown-${Date.now()}`);
|
|
847
|
+
debugLog(`${event.type}: added unknown permission to batch (now ${pendingPermissionBatch.length} pending)`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Reset the batch window timer (debounce)
|
|
851
|
+
// This gives more permissions a chance to arrive before we notify
|
|
852
|
+
if (permissionBatchTimeout) {
|
|
853
|
+
clearTimeout(permissionBatchTimeout);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
permissionBatchTimeout = setTimeout(async () => {
|
|
857
|
+
try {
|
|
858
|
+
await processPermissionBatch();
|
|
859
|
+
} catch (e) {
|
|
860
|
+
debugLog(`processPermissionBatch error: ${e.message}`);
|
|
861
|
+
}
|
|
862
|
+
}, PERMISSION_BATCH_WINDOW_MS);
|
|
863
|
+
|
|
864
|
+
debugLog(`${event.type}: batch window reset (will process in ${PERMISSION_BATCH_WINDOW_MS}ms if no more arrive)`);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ========================================
|
|
868
|
+
// NOTIFICATION 3: Question Request (BATCHED) - SDK v1.1.7+
|
|
869
|
+
// ========================================
|
|
870
|
+
// The "question" tool allows the LLM to ask users questions during execution.
|
|
871
|
+
// Events: question.asked, question.replied, question.rejected
|
|
872
|
+
//
|
|
873
|
+
// BATCHING: When multiple question requests arrive simultaneously,
|
|
874
|
+
// we batch them into a single notification instead of playing overlapping sounds.
|
|
875
|
+
// NOTE: Each question.asked event can contain multiple questions in its questions array.
|
|
876
|
+
if (event.type === "question.asked") {
|
|
877
|
+
// Capture question request ID and count of questions in this request
|
|
878
|
+
const questionId = event.properties?.id;
|
|
879
|
+
const questionsArray = event.properties?.questions;
|
|
880
|
+
const questionCount = Array.isArray(questionsArray) ? questionsArray.length : 1;
|
|
881
|
+
|
|
882
|
+
if (!questionId) {
|
|
883
|
+
debugLog(`${event.type}: question ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Add to the pending batch (avoid duplicates by checking ID)
|
|
887
|
+
// Store as object with id and questionCount for proper counting
|
|
888
|
+
const existingIndex = pendingQuestionBatch.findIndex(item => item.id === questionId);
|
|
889
|
+
if (questionId && existingIndex === -1) {
|
|
890
|
+
pendingQuestionBatch.push({ id: questionId, questionCount });
|
|
891
|
+
debugLog(`${event.type}: added ${questionId} with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
|
|
892
|
+
} else if (!questionId) {
|
|
893
|
+
// If no ID, still count it (use a placeholder)
|
|
894
|
+
pendingQuestionBatch.push({ id: `unknown-${Date.now()}`, questionCount });
|
|
895
|
+
debugLog(`${event.type}: added unknown question request with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Reset the batch window timer (debounce)
|
|
899
|
+
// This gives more questions a chance to arrive before we notify
|
|
900
|
+
if (questionBatchTimeout) {
|
|
901
|
+
clearTimeout(questionBatchTimeout);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
questionBatchTimeout = setTimeout(async () => {
|
|
905
|
+
try {
|
|
906
|
+
await processQuestionBatch();
|
|
907
|
+
} catch (e) {
|
|
908
|
+
debugLog(`processQuestionBatch error: ${e.message}`);
|
|
909
|
+
}
|
|
910
|
+
}, QUESTION_BATCH_WINDOW_MS);
|
|
911
|
+
|
|
912
|
+
debugLog(`${event.type}: batch window reset (will process in ${QUESTION_BATCH_WINDOW_MS}ms if no more arrive)`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Handle question.replied - user answered the question(s)
|
|
916
|
+
if (event.type === "question.replied") {
|
|
917
|
+
const repliedQuestionId = event.properties?.requestID;
|
|
918
|
+
const answers = event.properties?.answers;
|
|
919
|
+
|
|
920
|
+
// Remove this question from the pending batch (if still waiting)
|
|
921
|
+
// pendingQuestionBatch is now an array of { id, questionCount } objects
|
|
922
|
+
const existingIndex = pendingQuestionBatch.findIndex(item => item.id === repliedQuestionId);
|
|
923
|
+
if (repliedQuestionId && existingIndex !== -1) {
|
|
924
|
+
pendingQuestionBatch.splice(existingIndex, 1);
|
|
925
|
+
debugLog(`Question replied: removed ${repliedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// If batch is now empty and we have a pending batch timeout, we can cancel it
|
|
929
|
+
if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
|
|
930
|
+
clearTimeout(questionBatchTimeout);
|
|
931
|
+
questionBatchTimeout = null;
|
|
932
|
+
debugLog('Question replied: cancelled batch timeout (all questions handled)');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Clear active question ID
|
|
936
|
+
if (activeQuestionId === repliedQuestionId || activeQuestionId === undefined) {
|
|
937
|
+
activeQuestionId = null;
|
|
938
|
+
debugLog(`Question replied: cleared activeQuestionId ${repliedQuestionId || '(unknown)'}`);
|
|
939
|
+
}
|
|
940
|
+
lastUserActivityTime = Date.now();
|
|
941
|
+
cancelPendingReminder('question'); // Cancel question-specific reminder
|
|
942
|
+
debugLog(`Question replied: ${event.type} (answers=${JSON.stringify(answers)}) - cancelled question reminder`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Handle question.rejected - user dismissed the question
|
|
946
|
+
if (event.type === "question.rejected") {
|
|
947
|
+
const rejectedQuestionId = event.properties?.requestID;
|
|
948
|
+
|
|
949
|
+
// Remove this question from the pending batch (if still waiting)
|
|
950
|
+
// pendingQuestionBatch is now an array of { id, questionCount } objects
|
|
951
|
+
const existingIndex = pendingQuestionBatch.findIndex(item => item.id === rejectedQuestionId);
|
|
952
|
+
if (rejectedQuestionId && existingIndex !== -1) {
|
|
953
|
+
pendingQuestionBatch.splice(existingIndex, 1);
|
|
954
|
+
debugLog(`Question rejected: removed ${rejectedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// If batch is now empty and we have a pending batch timeout, we can cancel it
|
|
958
|
+
if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
|
|
959
|
+
clearTimeout(questionBatchTimeout);
|
|
960
|
+
questionBatchTimeout = null;
|
|
961
|
+
debugLog('Question rejected: cancelled batch timeout (all questions handled)');
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Clear active question ID
|
|
965
|
+
if (activeQuestionId === rejectedQuestionId || activeQuestionId === undefined) {
|
|
966
|
+
activeQuestionId = null;
|
|
967
|
+
debugLog(`Question rejected: cleared activeQuestionId ${rejectedQuestionId || '(unknown)'}`);
|
|
968
|
+
}
|
|
969
|
+
lastUserActivityTime = Date.now();
|
|
970
|
+
cancelPendingReminder('question'); // Cancel question-specific reminder
|
|
971
|
+
debugLog(`Question rejected: ${event.type} - cancelled question reminder`);
|
|
972
|
+
}
|
|
973
|
+
} catch (e) {
|
|
974
|
+
debugLog(`event handler error: ${e.message}`);
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|