opencode-smart-voice-notify 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -357,8 +357,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
357
357
  soundLoops = 1,
358
358
  ttsMessage,
359
359
  fallbackSound,
360
- permissionCount = 1, // Support permission count for batched notifications
361
- questionCount = 1 // Support question count for batched notifications
360
+ permissionCount, // Support permission count for batched notifications
361
+ questionCount // Support question count for batched notifications
362
362
  } = options;
363
363
 
364
364
  // Step 1: Play the immediate sound notification
@@ -418,27 +418,30 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
418
418
  ? config.permissionReminderTTSMessages
419
419
  : config.permissionTTSMessages;
420
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)
421
433
  if (count === 1) {
422
- // Single permission - use smart message (AI or static fallback)
423
- return await getSmartMessage('permission', isReminder, messages, { count });
434
+ return getRandomMessage(messages);
424
435
  } else {
425
- // Multiple permissions - use count-aware messages if available, or format dynamically
426
436
  const countMessages = isReminder
427
437
  ? config.permissionReminderTTSMessagesMultiple
428
438
  : config.permissionTTSMessagesMultiple;
429
439
 
430
440
  if (countMessages && countMessages.length > 0) {
431
- // Use configured multi-permission messages (replace {count} placeholder)
432
441
  const template = getRandomMessage(countMessages);
433
442
  return template.replace('{count}', count.toString());
434
- } else {
435
- // Try AI message with count context, fallback to dynamic message
436
- const aiMessage = await getSmartMessage('permission', isReminder, [], { count });
437
- if (aiMessage !== 'Notification') {
438
- return aiMessage;
439
- }
440
- return `Attention! There are ${count} permission requests waiting for your approval.`;
441
443
  }
444
+ return `Attention! There are ${count} permission requests waiting for your approval.`;
442
445
  }
443
446
  };
444
447
 
@@ -454,33 +457,39 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
454
457
  ? config.questionReminderTTSMessages
455
458
  : config.questionTTSMessages;
456
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)
457
472
  if (count === 1) {
458
- // Single question - use smart message (AI or static fallback)
459
- return await getSmartMessage('question', isReminder, messages, { count });
473
+ return getRandomMessage(messages);
460
474
  } else {
461
- // Multiple questions - use count-aware messages if available, or format dynamically
462
475
  const countMessages = isReminder
463
476
  ? config.questionReminderTTSMessagesMultiple
464
477
  : config.questionTTSMessagesMultiple;
465
478
 
466
479
  if (countMessages && countMessages.length > 0) {
467
- // Use configured multi-question messages (replace {count} placeholder)
468
480
  const template = getRandomMessage(countMessages);
469
481
  return template.replace('{count}', count.toString());
470
- } else {
471
- // Try AI message with count context, fallback to dynamic message
472
- const aiMessage = await getSmartMessage('question', isReminder, [], { count });
473
- if (aiMessage !== 'Notification') {
474
- return aiMessage;
475
- }
476
- return `Hey! I have ${count} questions for you. Please check your screen.`;
477
482
  }
483
+ return `Hey! I have ${count} questions for you. Please check your screen.`;
478
484
  }
479
485
  };
480
486
 
481
487
  /**
482
488
  * Process the batched permission requests as a single notification
483
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.
484
493
  */
485
494
  const processPermissionBatch = async () => {
486
495
  // Capture and clear the batch
@@ -500,40 +509,42 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
500
509
  // We track all IDs in the batch for proper cleanup
501
510
  activePermissionId = batch[0];
502
511
 
503
- // Show toast with count
512
+ // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
504
513
  const toastMessage = batchCount === 1
505
514
  ? "⚠️ Permission request requires your attention"
506
515
  : `⚠️ ${batchCount} permission requests require your attention`;
507
- await showToast(toastMessage, "warning", 8000);
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);
508
521
 
509
- // CHECK: Did user already respond while we were showing toast?
522
+ // CHECK: Did user already respond while sound was playing?
510
523
  if (pendingPermissionBatch.length > 0) {
511
- // New permissions arrived during toast - they'll be handled in next batch
512
- debugLog('processPermissionBatch: new permissions arrived during toast');
524
+ // New permissions arrived during sound - they'll be handled in next batch
525
+ debugLog('processPermissionBatch: new permissions arrived during sound');
513
526
  }
514
527
 
515
- // Check if any permission was already replied to
528
+ // Step 3: Check race condition - did user respond during sound?
516
529
  if (activePermissionId === null) {
517
- debugLog('processPermissionBatch: aborted - user already responded');
530
+ debugLog('processPermissionBatch: user responded during sound - aborting');
518
531
  return;
519
532
  }
520
533
 
521
- // Get count-aware TTS message
522
- const ttsMessage = await getPermissionMessage(batchCount, false);
534
+ // Step 4: Generate AI message for reminder AFTER sound played
523
535
  const reminderMessage = await getPermissionMessage(batchCount, true);
524
-
525
- // Smart notification: sound first, TTS reminder later
526
- await smartNotify('permission', {
527
- soundFile: config.permissionSound,
528
- soundLoops: batchCount === 1 ? 2 : Math.min(3, batchCount), // More loops for more permissions
529
- ttsMessage: reminderMessage,
530
- fallbackSound: config.permissionSound,
531
- // Pass count for potential use in notification
532
- permissionCount: batchCount
533
- });
534
536
 
535
- // Speak immediately if in TTS-first or both mode (with count-aware message)
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
536
546
  if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
547
+ const ttsMessage = await getPermissionMessage(batchCount, false);
537
548
  await tts.wakeMonitor();
538
549
  await tts.forceVolume();
539
550
  await tts.speak(ttsMessage, {
@@ -552,6 +563,9 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
552
563
  /**
553
564
  * Process the batched question requests as a single notification (SDK v1.1.7+)
554
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.
555
569
  */
556
570
  const processQuestionBatch = async () => {
557
571
  // Capture and clear the batch
@@ -574,41 +588,41 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
574
588
  // We track all IDs in the batch for proper cleanup
575
589
  activeQuestionId = batch[0]?.id;
576
590
 
577
- // Show toast with count
591
+ // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
578
592
  const toastMessage = totalQuestionCount === 1
579
593
  ? "❓ The agent has a question for you"
580
594
  : `❓ The agent has ${totalQuestionCount} questions for you`;
581
- await showToast(toastMessage, "info", 8000);
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);
582
599
 
583
- // CHECK: Did user already respond while we were showing toast?
600
+ // CHECK: Did user already respond while sound was playing?
584
601
  if (pendingQuestionBatch.length > 0) {
585
- // New questions arrived during toast - they'll be handled in next batch
586
- debugLog('processQuestionBatch: new questions arrived during toast');
602
+ // New questions arrived during sound - they'll be handled in next batch
603
+ debugLog('processQuestionBatch: new questions arrived during sound');
587
604
  }
588
605
 
589
- // Check if any question was already replied to or rejected
606
+ // Step 3: Check race condition - did user respond during sound?
590
607
  if (activeQuestionId === null) {
591
- debugLog('processQuestionBatch: aborted - user already responded');
608
+ debugLog('processQuestionBatch: user responded during sound - aborting');
592
609
  return;
593
610
  }
594
611
 
595
- // Get count-aware TTS message (uses total question count, not request count)
596
- const ttsMessage = await getQuestionMessage(totalQuestionCount, false);
612
+ // Step 4: Generate AI message for reminder AFTER sound played
597
613
  const reminderMessage = await getQuestionMessage(totalQuestionCount, true);
598
614
 
599
- // Smart notification: sound first, TTS reminder later
600
- // Sound plays 2 times by default (matching permission behavior)
601
- await smartNotify('question', {
602
- soundFile: config.questionSound,
603
- soundLoops: 2, // Fixed at 2 loops to match permission sound behavior
604
- ttsMessage: reminderMessage,
605
- fallbackSound: config.questionSound,
606
- // Pass count for use in reminders
607
- questionCount: totalQuestionCount
608
- });
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
+ }
609
622
 
610
- // Speak immediately if in TTS-first or both mode (with count-aware message)
623
+ // Step 6: If TTS-first or both mode, generate and speak immediate message
611
624
  if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
625
+ const ttsMessage = await getQuestionMessage(totalQuestionCount, false);
612
626
  await tts.wakeMonitor();
613
627
  await tts.forceVolume();
614
628
  await tts.speak(ttsMessage, {
@@ -747,6 +761,9 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
747
761
 
748
762
  // ========================================
749
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.
750
767
  // ========================================
751
768
  if (event.type === "session.idle") {
752
769
  const sessionID = event.properties?.sessionID;
@@ -764,18 +781,42 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
764
781
  lastSessionIdleTime = Date.now();
765
782
 
766
783
  debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
767
- await showToast("✅ Agent has finished working", "success", 5000);
768
-
769
- // Get smart message for idle notification (AI or static fallback)
770
- const idleTtsMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
771
-
772
- // Smart notification: sound first, TTS reminder later
773
- await smartNotify('idle', {
774
- soundFile: config.idleSound,
775
- soundLoops: 1,
776
- ttsMessage: idleTtsMessage,
777
- fallbackSound: config.idleSound
778
- });
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
+ }
779
820
  }
780
821
 
781
822
  // ========================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-smart-voice-notify",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI) and intelligent reminder system",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -24,12 +24,23 @@ export async function generateAIMessage(promptType, context = {}) {
24
24
  }
25
25
 
26
26
  // Get the prompt for this type
27
- const prompt = config.aiPrompts?.[promptType];
27
+ let prompt = config.aiPrompts?.[promptType];
28
28
  if (!prompt) {
29
- console.error(`[AI Messages] No prompt configured for type: ${promptType}`);
30
29
  return null;
31
30
  }
32
31
 
32
+ // Inject count context if multiple items
33
+ if (context.count && context.count > 1) {
34
+ // Use type-specific terminology
35
+ let itemType = 'items';
36
+ if (context.type === 'question') {
37
+ itemType = 'questions';
38
+ } else if (context.type === 'permission') {
39
+ itemType = 'permission requests';
40
+ }
41
+ prompt = `${prompt} Important: There are ${context.count} ${itemType} (not just one) waiting for the user's attention. Mention the count in your message.`;
42
+ }
43
+
33
44
  try {
34
45
  // Build headers
35
46
  const headers = { 'Content-Type': 'application/json' };
@@ -72,8 +83,6 @@ export async function generateAIMessage(promptType, context = {}) {
72
83
  clearTimeout(timeout);
73
84
 
74
85
  if (!response.ok) {
75
- const errorText = await response.text().catch(() => 'Unknown error');
76
- console.error(`[AI Messages] API error ${response.status}: ${errorText}`);
77
86
  return null;
78
87
  }
79
88
 
@@ -83,7 +92,6 @@ export async function generateAIMessage(promptType, context = {}) {
83
92
  const message = data.choices?.[0]?.message?.content?.trim();
84
93
 
85
94
  if (!message) {
86
- console.error('[AI Messages] Empty response from AI');
87
95
  return null;
88
96
  }
89
97
 
@@ -92,18 +100,12 @@ export async function generateAIMessage(promptType, context = {}) {
92
100
 
93
101
  // Validate message length (sanity check)
94
102
  if (cleanMessage.length < 5 || cleanMessage.length > 200) {
95
- console.error(`[AI Messages] Message length invalid: ${cleanMessage.length} chars`);
96
103
  return null;
97
104
  }
98
105
 
99
106
  return cleanMessage;
100
107
 
101
108
  } catch (error) {
102
- if (error.name === 'AbortError') {
103
- console.error(`[AI Messages] Request timed out after ${config.aiTimeout || 15000}ms`);
104
- } else {
105
- console.error(`[AI Messages] Error: ${error.message}`);
106
- }
107
109
  return null;
108
110
  }
109
111
  }
@@ -127,14 +129,10 @@ export async function getSmartMessage(eventType, isReminder, staticMessages, con
127
129
  try {
128
130
  const aiMessage = await generateAIMessage(promptType, context);
129
131
  if (aiMessage) {
130
- // Log success for debugging
131
- if (config.debugLog) {
132
- console.log(`[AI Messages] Generated: ${aiMessage}`);
133
- }
134
132
  return aiMessage;
135
133
  }
136
134
  } catch (error) {
137
- console.error(`[AI Messages] Generation failed: ${error.message}`);
135
+ // Silently fall through to fallback
138
136
  }
139
137
 
140
138
  // Check if fallback is disabled