diff --git a/README.md b/README.md index fe25fed..f1f0af8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ > **Disclaimer**: This project is not built by the OpenCode team and is not affiliated with [OpenCode](https://opencode.ai) in any way. It is an independent community plugin. +> **Fork Notice**: This is a fork of the original [opencode-smart-voice-notify](https://github.com/MasuRii/opencode-smart-voice-notify) plugin. It adds **WSL2 support** (via PulseAudio/Windows interop) and **increases AI message limits** (up to 500 characters) for more detailed and expressive notifications. + A smart voice notification plugin for [OpenCode](https://opencode.ai) with **multiple TTS engines**, native desktop notifications, and an intelligent reminder system. image diff --git a/index.js b/index.js index 8771bea..b3c22eb 100644 --- a/index.js +++ b/index.js @@ -845,13 +845,15 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Step 5: If TTS-first or both mode, generate and speak immediate message if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') { - const ttsMessage = await getPermissionMessage(batchCount, false, aiContext); - await tts.wakeMonitor(); - await tts.forceVolume(); - await tts.speak(ttsMessage, { - enableTTS: true, - fallbackSound: config.permissionSound - }); + // Don't await the TTS generation/playback to avoid blocking the terminal + getPermissionMessage(batchCount, false, aiContext).then(async (ttsMessage) => { + await tts.wakeMonitor(); + await tts.forceVolume(); + await tts.speak(ttsMessage, { + enableTTS: true, + fallbackSound: config.permissionSound + }); + }).catch(e => debugLog(`TTS error: ${e.message}`)); } // Final check: if user responded during notification, cancel scheduled reminder @@ -951,13 +953,15 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Step 5: If TTS-first or both mode, generate and speak immediate message if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') { - const ttsMessage = await getQuestionMessage(totalQuestionCount, false, aiContext); - await tts.wakeMonitor(); - await tts.forceVolume(); - await tts.speak(ttsMessage, { - enableTTS: true, - fallbackSound: config.questionSound - }); + // Don't await the TTS generation/playback to avoid blocking the terminal + getQuestionMessage(totalQuestionCount, false, aiContext).then(async (ttsMessage) => { + await tts.wakeMonitor(); + await tts.forceVolume(); + await tts.speak(ttsMessage, { + enableTTS: true, + fallbackSound: config.questionSound + }); + }).catch(e => debugLog(`TTS error: ${e.message}`)); } // Final check: if user responded during notification, cancel scheduled reminder @@ -1135,6 +1139,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Fetch session details for context-aware AI and sub-session filtering let sessionData = null; + let sessionMessages = []; try { const session = await client.session.get({ path: { id: sessionID } }); sessionData = session?.data; @@ -1142,8 +1147,40 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc debugLog(`session.idle: skipped (sub-session ${sessionID})`); return; } + + // Fetch messages to get context on what was done + try { + if (client.message && typeof client.message.list === 'function') { + const messagesResult = await client.message.list({ path: { sessionID } }); + sessionMessages = messagesResult?.data || []; + } + } catch (msgError) { + debugLog(`session.idle: failed to fetch messages: ${msgError.message}`); + } } catch (e) {} + // Analyze messages for context + let lastUserMessage = ''; + let lastAssistantMessage = ''; + let hasErrors = false; + + if (sessionMessages.length > 0) { + // Find last user message + const userMsgs = sessionMessages.filter(m => m.role === 'user'); + if (userMsgs.length > 0) { + lastUserMessage = userMsgs[userMsgs.length - 1].content; + } + + // Find last assistant message + const assistantMsgs = sessionMessages.filter(m => m.role === 'assistant'); + if (assistantMsgs.length > 0) { + lastAssistantMessage = assistantMsgs[assistantMsgs.length - 1].content; + } + + // Check for errors (simple heuristic) + hasErrors = sessionMessages.some(m => m.role === 'error' || (m.role === 'tool' && m.content && m.content.toLowerCase().includes('error'))); + } + // Build context for AI message generation (used when enableContextAwareAI is true) // Note: SDK's Project type doesn't have 'name' property, so we use derivedProjectName const aiContext = { @@ -1153,7 +1190,10 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc files: sessionData.summary.files, additions: sessionData.summary.additions, deletions: sessionData.summary.deletions - } : undefined + } : undefined, + lastUserMessage, + lastAssistantMessage, + hasErrors }; // Record the time session went idle - used to filter out pre-idle messages @@ -1207,13 +1247,15 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Step 5: If TTS-first or both mode, generate and speak immediate message if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') { - const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages, aiContext); - await tts.wakeMonitor(); - await tts.forceVolume(); - await tts.speak(ttsMessage, { - enableTTS: true, - fallbackSound: config.idleSound - }); + // Don't await the TTS generation/playback to avoid blocking the terminal + getSmartMessage('idle', false, config.idleTTSMessages, aiContext).then(async (ttsMessage) => { + await tts.wakeMonitor(); + await tts.forceVolume(); + await tts.speak(ttsMessage, { + enableTTS: true, + fallbackSound: config.idleSound + }); + }).catch(e => debugLog(`TTS error: ${e.message}`)); } } @@ -1287,13 +1329,15 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc // Step 4: If TTS-first or both mode, generate and speak immediate message if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') { - const ttsMessage = await getErrorMessage(1, false); - await tts.wakeMonitor(); - await tts.forceVolume(); - await tts.speak(ttsMessage, { - enableTTS: true, - fallbackSound: config.errorSound - }); + // Don't await the TTS generation/playback to avoid blocking the terminal + getErrorMessage(1, false).then(async (ttsMessage) => { + await tts.wakeMonitor(); + await tts.forceVolume(); + await tts.speak(ttsMessage, { + enableTTS: true, + fallbackSound: config.errorSound + }); + }).catch(e => debugLog(`TTS error: ${e.message}`)); } } diff --git a/tests/e2e/context-aware-ai.test.js b/tests/e2e/context-aware-ai.test.js index 8a00c8f..66515fe 100644 --- a/tests/e2e/context-aware-ai.test.js +++ b/tests/e2e/context-aware-ai.test.js @@ -175,7 +175,7 @@ describe('Context-Aware AI Feature (Issue #9)', () => { expect(prompt).toContain('Project: "CodeRefactor"'); expect(prompt).toContain('Task: "Refactor database layer"'); - expect(prompt).toContain('Changes:'); + expect(prompt).toContain('Stats:'); expect(prompt).toContain('5 file(s) modified'); expect(prompt).toContain('+120 lines'); expect(prompt).toContain('-45 lines'); diff --git a/tests/unit/error-handler.test.js b/tests/unit/error-handler.test.js index d946b03..50d8545 100644 --- a/tests/unit/error-handler.test.js +++ b/tests/unit/error-handler.test.js @@ -141,7 +141,7 @@ describe('error handler functionality', () => { test('error prompt mentions error/problem context', () => { const prompt = config.aiPrompts.error.toLowerCase(); - expect(prompt.includes('error') || prompt.includes('problem') || prompt.includes('wrong')).toBe(true); + expect(prompt.includes('error') || prompt.includes('problem') || prompt.includes('wrong') || prompt.includes('broke')).toBe(true); }); test('errorReminder prompt conveys urgency', () => { diff --git a/util/ai-messages.js b/util/ai-messages.js index 9a48b17..19ee555 100644 --- a/util/ai-messages.js +++ b/util/ai-messages.js @@ -92,13 +92,32 @@ export async function generateAIMessage(promptType, context = {}) { if (files !== undefined) summaryParts.push(`${files} file(s) modified`); if (additions !== undefined) summaryParts.push(`+${additions} lines`); if (deletions !== undefined) summaryParts.push(`-${deletions} lines`); - contextParts.push(`Changes: ${summaryParts.join(', ')}`); + contextParts.push(`Stats: ${summaryParts.join(', ')}`); debugLog(`generateAIMessage: context includes sessionSummary (files=${files}, additions=${additions}, deletions=${deletions})`, config); } } + + if (context.lastUserMessage) { + // Truncate to avoid huge context window usage + const truncated = context.lastUserMessage.length > 200 ? context.lastUserMessage.substring(0, 200) + '...' : context.lastUserMessage; + contextParts.push(`User Goal: "${truncated}"`); + debugLog(`generateAIMessage: context includes lastUserMessage`, config); + } + + if (context.lastAssistantMessage) { + // Truncate + const truncated = context.lastAssistantMessage.length > 300 ? context.lastAssistantMessage.substring(0, 300) + '...' : context.lastAssistantMessage; + contextParts.push(`Work Done: "${truncated}"`); + debugLog(`generateAIMessage: context includes lastAssistantMessage`, config); + } + + if (context.hasErrors) { + contextParts.push(`Note: The session encountered some errors or blockers.`); + debugLog(`generateAIMessage: context includes hasErrors=true`, config); + } if (contextParts.length > 0) { - prompt = `${prompt}\n\nContext for this notification:\n${contextParts.join('\n')}\n\nIncorporate relevant context into your message to make it more specific and helpful (e.g., mention the project name or what was worked on).`; + prompt = `${prompt}\n\nContext for this notification:\n${contextParts.join('\n')}\n\nUse this context to explain WHAT was done (the tasks/actions), not just file stats. If there were errors/blockers, mention them.`; debugLog(`generateAIMessage: injected ${contextParts.length} context part(s) into prompt`, config); } else { debugLog(`generateAIMessage: no context available to inject (projectName, sessionTitle, sessionSummary all empty)`, config); diff --git a/util/config.js b/util/config.js index 4eceaaf..12717da 100644 --- a/util/config.js +++ b/util/config.js @@ -259,14 +259,14 @@ export const getDefaultConfigObject = () => ({ aiFallbackToStatic: true, enableContextAwareAI: false, aiPrompts: { - idle: "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.", - permission: "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.", - question: "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.", - error: "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.", - idleReminder: "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.", - permissionReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.", - questionReminder: "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.", - errorReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes." + idle: "Summarize the actual work done in 1-2 sarcastic or ironic sentences. Focus on the actions taken (refactoring, fixing bugs) rather than just file counts. If there were errors or blockers, mention them with a dry wit. Be concise but useful. No fluff. Output only the message, no quotes.", + permission: "Generate a single brief, urgent but slightly annoyed sentence (max 15 words) asking the user to approve a permission request so I can actually do my job. Output only the message, no quotes.", + question: "Generate a single brief, witty sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.", + error: "Generate a single brief, dryly sarcastic sentence (max 15 words) announcing that something broke and needs attention. Output only the message, no quotes.", + idleReminder: "Generate a single brief, sarcastic reminder (max 15 words) that a completed task is gathering dust waiting for review. Be annoyingly persistent. Output only the message, no quotes.", + permissionReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey extreme impatience. Output only the message, no quotes.", + questionReminder: "Generate a single brief, persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Use dry humor. Output only the message, no quotes.", + errorReminder: "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Be dramatic. Output only the message, no quotes." }, idleSound: 'assets/Soft-high-tech-notification-sound-effect.mp3', permissionSound: 'assets/Machine-alert-beep-sound-effect.mp3', @@ -743,14 +743,14 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => { // The AI will generate a short message based on these prompts // Keep prompts concise - they're sent with each notification "aiPrompts": ${formatJSON(overrides.aiPrompts || { - "idle": "Generate a single brief, friendly notification sentence (max 15 words) saying a coding task is complete. Be encouraging and warm. Output only the message, no quotes.", - "permission": "Generate a single brief, urgent but friendly notification sentence (max 15 words) asking the user to approve a permission request. Output only the message, no quotes.", - "question": "Generate a single brief, polite notification sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.", - "error": "Generate a single brief, concerned but calm notification sentence (max 15 words) saying an error occurred and needs attention. Output only the message, no quotes.", - "idleReminder": "Generate a single brief, gentle reminder sentence (max 15 words) that a completed task is waiting for review. Be slightly more insistent. Output only the message, no quotes.", - "permissionReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey importance. Output only the message, no quotes.", - "questionReminder": "Generate a single brief, polite but persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Output only the message, no quotes.", - "errorReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Convey urgency. Output only the message, no quotes." + "idle": "Summarize the actual work done in 1-2 sarcastic or ironic sentences. Focus on the actions taken (refactoring, fixing bugs) rather than just file counts. If there were errors or blockers, mention them with a dry wit. Be concise but useful. No fluff. Output only the message, no quotes.", + "permission": "Generate a single brief, urgent but slightly annoyed sentence (max 15 words) asking the user to approve a permission request so I can actually do my job. Output only the message, no quotes.", + "question": "Generate a single brief, witty sentence (max 15 words) saying the assistant has a question and needs user input. Output only the message, no quotes.", + "error": "Generate a single brief, dryly sarcastic sentence (max 15 words) announcing that something broke and needs attention. Output only the message, no quotes.", + "idleReminder": "Generate a single brief, sarcastic reminder (max 15 words) that a completed task is gathering dust waiting for review. Be annoyingly persistent. Output only the message, no quotes.", + "permissionReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that permission approval is still needed. Convey extreme impatience. Output only the message, no quotes.", + "questionReminder": "Generate a single brief, persistent reminder sentence (max 15 words) that a question is still waiting for an answer. Use dry humor. Output only the message, no quotes.", + "errorReminder": "Generate a single brief, urgent reminder sentence (max 15 words) that an error still needs attention. Be dramatic. Output only the message, no quotes." }, 4)}, // ============================================================