Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<img width="1456" height="720" alt="image" src="https://github.com/user-attachments/assets/52ccf357-2548-400b-a346-6362f2fc3180" />
Expand Down
102 changes: 73 additions & 29 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1135,15 +1139,48 @@ 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;
if (sessionData?.parentID) {
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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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}`));
}
}

Expand Down Expand Up @@ -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}`));
}
}

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/context-aware-ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/error-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 21 additions & 2 deletions util/ai-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 16 additions & 16 deletions util/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)},

// ============================================================
Expand Down