diff --git a/packages/koe-core/src/providers/groq-helpers.ts b/packages/koe-core/src/providers/groq-helpers.ts index dd1ab10..3788b14 100644 --- a/packages/koe-core/src/providers/groq-helpers.ts +++ b/packages/koe-core/src/providers/groq-helpers.ts @@ -3,25 +3,34 @@ import { DEFAULT_CUSTOM_PROMPT } from '../constants'; /** * Resolves the prompt to use for AI enhancement based on the selected style. */ -export function resolveEnhancementPrompt(promptStyle: string = 'Clean', customPrompt: string = ''): string { +export function resolveEnhancementPrompt(promptStyle: string = 'Clean', customPrompt: string = '', smartContext: string = ''): string { + const contextPrefix = smartContext ? `Context: ${smartContext}. ` : ''; const trimmedPrompt = customPrompt.trim(); if (trimmedPrompt) { return trimmedPrompt; } if (promptStyle === 'Professional' || promptStyle === 'Formal') { - return 'Refine this dictated text with a formal, professional tone. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; + return contextPrefix + 'Refine this dictated text with a formal, professional tone. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; } if (promptStyle === 'Casual') { - return 'Refine this dictated text so it stays casual and conversational. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; + return contextPrefix + 'Refine this dictated text so it stays casual and conversational. Keep the meaning intact, fix punctuation and grammar, remove filler only when it is clearly filler, never use em dashes, and do not add transcript tags or any other wrapper markup.'; } if (promptStyle === 'Concise' || promptStyle === 'Bullets') { - return 'Refine this dictated text into a tighter version with less filler while keeping the original meaning. Remove filler words like um, uh, and obvious filler mistranscriptions like ohms only when they are clearly filler, not when they are literal or technical. Never use em dashes, and do not add transcript tags or any other wrapper markup.'; + return contextPrefix + 'Refine this dictated text into a tighter version with less filler while keeping the original meaning. Remove filler words like um, uh, and obvious filler mistranscriptions like ohms only when they are clearly filler, not when they are literal or technical. Never use em dashes, and do not add transcript tags or any other wrapper markup.'; } - return DEFAULT_CUSTOM_PROMPT; + if (promptStyle === 'Meeting Notes' || promptStyle === 'Meeting') { + return contextPrefix + 'Refine this meeting transcript into a structured set of meeting notes. Include a summary of the main points and a clear list of action items with owners if mentioned. Maintain a professional tone and ensure the output is concise and actionable.'; + } + + if (promptStyle === 'Nigerian Pidgin') { + return contextPrefix + 'Refine this dictated text in Nigerian Pidgin English. Maintain the authentic Pidgin grammar and flow. Use standard Pidgin spellings (e.g., "sabi", "wetin", "pikin", "don", "go", "dey"). Fix punctuation and remove obvious verbal filler while preserving the original meaning and vibrant tone. Do not translate it to standard English.'; + } + + return contextPrefix + DEFAULT_CUSTOM_PROMPT; } export function parseErrorMessage(payload: any, fallback: string): string { diff --git a/src/main/ipc.js b/src/main/ipc.js index ffa16f0..9333ba9 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -8,7 +8,7 @@ const { retryAndPasteTranscript } = require('./services/retry-transcript'); const pendingRetryService = require('./services/pending-retry'); const sessionManager = require('./services/transcription-session-manager'); const { closeSettingsWindow } = require('./settings-window'); -const { updateHotkey } = require('./shortcuts'); +const { updateHotkey, handleRecordingToggle } = require('./shortcuts'); const { applyLaunchOnStartupPreference } = require('./services/startup'); const { applyAutoUpdatePreference } = require('./services/updater'); const logger = require('./services/logger'); @@ -74,8 +74,25 @@ function setupIpcHandlers(mainWindow) { ipcMain.handle(CHANNELS.TEST_GROQ_KEY, async (event, apiKey) => validateApiKey(apiKey)); ipcMain.handle(CHANNELS.GET_USAGE_STATS, async () => rateLimiter.getUsageStats()); ipcMain.handle(CHANNELS.GET_HISTORY, async () => historyService.getHistory()); + ipcMain.handle(CHANNELS.SEARCH_HISTORY, async (event, query) => historyService.searchHistory(query)); ipcMain.handle(CHANNELS.CLEAR_HISTORY, async () => historyService.clearHistory()); + ipcMain.handle(CHANNELS.GET_TASKS, async () => { + const historyStore = new Store({ name: 'tasks-history' }); + return historyStore.get('tasks', []); + }); + + ipcMain.handle(CHANNELS.TOGGLE_TASK, async (event, taskId) => { + const historyStore = new Store({ name: 'tasks-history' }); + const tasks = historyStore.get('tasks', []); + const task = tasks.find(t => t.id === taskId); + if (task) { + task.completed = !task.completed; + historyStore.set('tasks', tasks); + } + return tasks; + }); + ipcMain.handle(CHANNELS.RETRY_HISTORY_ENTRY, async (event, entryId) => { return retryAndPasteTranscript(entryId, { beforePaste: hideSettingsBeforePaste @@ -114,6 +131,11 @@ function setupIpcHandlers(mainWindow) { closeSettingsWindow(); }); + ipcMain.on(CHANNELS.TOGGLE_RECORDING, (event, options) => { + handleRecordingToggle(mainWindow, options); + }); + + ipcMain.on(CHANNELS.AUDIO_SEGMENT, (event, audioData) => { sessionManager.handleSegment(audioData).catch((error) => { logger.error('[Pipeline] Failed to enqueue audio segment:', error); diff --git a/src/main/main.js b/src/main/main.js index 6f95e4a..0e2b8e0 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -6,6 +6,7 @@ const { setupIpcHandlers } = require('./ipc'); const { createSettingsWindow } = require('./settings-window'); const { getPillBounds, pinPillWindow } = require('./services/pill-window'); const { getSetting } = require('./services/settings'); +const meetingDetector = require('./services/meeting-detector'); const { applyLaunchOnStartupPreference } = require('./services/startup'); const { applyAutoUpdatePreference } = require('./services/updater'); const sessionManager = require('./services/transcription-session-manager'); @@ -81,6 +82,7 @@ app.whenReady().then(() => { setupTray(mainWindow); registerShortcuts(mainWindow); setupIpcHandlers(mainWindow); + meetingDetector.init(mainWindow); applyLaunchOnStartupPreference(getSetting('launchOnStartup') !== false); applyAutoUpdatePreference(getSetting('autoUpdate') !== false); diff --git a/src/main/preload.js b/src/main/preload.js index 05aed7d..2fb6830 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -17,6 +17,16 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on(CHANNELS.WINDOW_ANIMATE_IN, () => callback()); }, + onMeetingDetected: (callback) => { + ipcRenderer.removeAllListeners(CHANNELS.MEETING_DETECTED); + ipcRenderer.on(CHANNELS.MEETING_DETECTED, () => callback()); + }, + + onAiInsight: (callback) => { + ipcRenderer.removeAllListeners(CHANNELS.AI_INSIGHT); + ipcRenderer.on(CHANNELS.AI_INSIGHT, (event, insight) => callback(insight)); + }, + // Settings getSettings: () => ipcRenderer.invoke(CHANNELS.GET_SETTINGS), saveSettings: (settings) => ipcRenderer.invoke(CHANNELS.SAVE_SETTINGS, settings), @@ -30,15 +40,19 @@ contextBridge.exposeInMainWorld('api', { // History getHistory: () => ipcRenderer.invoke(CHANNELS.GET_HISTORY), + searchHistory: (query) => ipcRenderer.invoke(CHANNELS.SEARCH_HISTORY, query), clearHistory: () => ipcRenderer.invoke(CHANNELS.CLEAR_HISTORY), retryHistoryEntry: (entryId) => ipcRenderer.invoke(CHANNELS.RETRY_HISTORY_ENTRY, entryId), retryLastTranscript: () => ipcRenderer.invoke(CHANNELS.RETRY_LAST_TRANSCRIPT), exportHistory: (format) => ipcRenderer.invoke('history:export', format), + getTasks: () => ipcRenderer.invoke(CHANNELS.GET_TASKS), + toggleTask: (taskId) => ipcRenderer.invoke(CHANNELS.TOGGLE_TASK, taskId), // Logs openLogsFolder: () => ipcRenderer.invoke('app:open-logs'), // Audio + toggleRecording: (options = {}) => ipcRenderer.send(CHANNELS.TOGGLE_RECORDING, options), sendAudioSegment: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SEGMENT, payload), sendAudioChunk: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SEGMENT, payload), notifyAudioSessionStopped: (payload) => ipcRenderer.send(CHANNELS.AUDIO_SESSION_STOPPED, payload), diff --git a/src/main/services/email-service.js b/src/main/services/email-service.js new file mode 100644 index 0000000..dfb3dac --- /dev/null +++ b/src/main/services/email-service.js @@ -0,0 +1,19 @@ +const logger = require('./logger'); + +class EmailService { + async sendMeetingSummary(to, summary) { + if (!to) { + logger.warn('[EmailService] No email address provided for summary.'); + return; + } + + // Simulating email sending + logger.info(`[EmailService] Sending meeting summary to ${to}...`); + logger.debug('[EmailService] Summary content:', summary); + + // In a real app, you'd use a service like SendGrid, Mailgun, or Nodemailer + return Promise.resolve({ success: true }); + } +} + +module.exports = new EmailService(); diff --git a/src/main/services/history.js b/src/main/services/history.js index 376b4ed..1b610b5 100644 --- a/src/main/services/history.js +++ b/src/main/services/history.js @@ -75,6 +75,17 @@ function getEntryRawText(entry) { return entry?.rawText || entry?.text || ''; } +function searchHistory(query) { + const entries = getHistory(); + if (!query) return entries; + + const lowerQuery = query.toLowerCase(); + return entries.filter(entry => + (entry.refinedText || '').toLowerCase().includes(lowerQuery) || + (entry.rawText || '').toLowerCase().includes(lowerQuery) + ); +} + function clearHistory() { getStore().set('entries', []); return []; @@ -86,5 +97,6 @@ module.exports = { getHistoryEntryById, getLatestEntry, getEntryRawText, + searchHistory, clearHistory }; diff --git a/src/main/services/meeting-detector.js b/src/main/services/meeting-detector.js new file mode 100644 index 0000000..c2a2698 --- /dev/null +++ b/src/main/services/meeting-detector.js @@ -0,0 +1,82 @@ +const { exec } = require('child_process'); +const { Notification } = require('electron'); +const logger = require('./logger'); + +const MEETING_PROCESSES = [ + 'Zoom.exe', + 'Teams.exe', + 'ms-teams.exe', + 'Slack.exe', + 'Webex.exe' +]; + +class MeetingDetector { + constructor() { + this.interval = null; + this.isMeetingActive = false; + this.mainWindow = null; + } + + init(mainWindow) { + this.mainWindow = mainWindow; + this.startMonitoring(); + } + + startMonitoring() { + if (this.interval) return; + + this.interval = setInterval(() => { + this.checkMeetingStatus(); + }, 30000); // Check every 30 seconds + + this.checkMeetingStatus(); + } + + stopMonitoring() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + checkMeetingStatus() { + const command = process.platform === 'win32' ? 'tasklist' : 'ps -e'; + + exec(command, (error, stdout) => { + if (error) { + logger.error('[MeetingDetector] Failed to list processes:', error); + return; + } + + const active = MEETING_PROCESSES.some(proc => stdout.includes(proc)); + + if (active && !this.isMeetingActive) { + this.onMeetingDetected(); + } + + this.isMeetingActive = active; + }); + } + + onMeetingDetected() { + logger.info('[MeetingDetector] Meeting detected!'); + + const notification = new Notification({ + title: 'Koe - Meeting Detected', + body: 'Would you like to join the meeting to take notes and summarize?', + silent: false + }); + + notification.show(); + + notification.on('click', () => { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + const { CHANNELS } = require('../../shared/constants'); + this.mainWindow.webContents.send(CHANNELS.MEETING_DETECTED); + this.mainWindow.show(); + } + }); + } +} + +module.exports = new MeetingDetector(); diff --git a/src/main/services/settings.js b/src/main/services/settings.js index 2842e8b..31f2e51 100644 --- a/src/main/services/settings.js +++ b/src/main/services/settings.js @@ -22,6 +22,13 @@ const SETTINGS_MIGRATIONS = [ changes: { cloudProcessingEnabled: false } + }, + { + id: '2026-03-20-update-default-hotkey', + mode: 'preserve', + changes: { + hotkey: 'Alt+Shift+S' + } } ]; diff --git a/src/main/services/transcription-session-manager.js b/src/main/services/transcription-session-manager.js index 6307bec..6ce293b 100644 --- a/src/main/services/transcription-session-manager.js +++ b/src/main/services/transcription-session-manager.js @@ -5,9 +5,13 @@ const { } = require('@koe/core'); const path = require('path'); const { Worker } = require('worker_threads'); +const Store = require('electron-store').default || require('electron-store'); +const { v4: uuidv4 } = require('uuid'); const { CHANNELS } = require('../../shared/constants'); const { getSettings } = require('./settings'); const { autoPaste, writeToClipboard } = require('./clipboard'); +const { Notification } = require('electron'); +const emailService = require('./email-service'); const historyService = require('./history'); const rateLimiter = require('./rate-limiter'); const pendingRetryService = require('./pending-retry'); @@ -92,22 +96,23 @@ class TranscriptionSessionManager { } } - createSession(sessionId, settings = getSettings()) { + createSession(sessionId, settings = getSettings(), overrides = {}) { const sessionSettings = { groqApiKey: settings.groqApiKey || '', language: settings.language || 'auto', - promptStyle: settings.promptStyle || 'Clean', + promptStyle: overrides.promptStyle || settings.promptStyle || 'Clean', customPrompt: settings.customPrompt || '', model: settings.model || 'whisper-large-v3-turbo', enhanceText: settings.enhanceText !== false, - autoPaste: settings.autoPaste !== false + autoPaste: settings.autoPaste !== false, + smartContext: settings.smartContext || '' }; return this.coordinator.createSession(sessionId, sessionSettings); } - getOrCreateSession(sessionId, settings = getSettings()) { - return this.coordinator.getSession(sessionId) || this.createSession(sessionId, settings); + getOrCreateSession(sessionId, settings = getSettings(), overrides = {}) { + return this.coordinator.getSession(sessionId) || this.createSession(sessionId, settings, overrides); } sendStatus(status) { @@ -145,7 +150,7 @@ class TranscriptionSessionManager { async handleSegment(audioData) { const settings = getSettings(); - const session = this.getOrCreateSession(audioData.sessionId, settings); + const session = this.getOrCreateSession(audioData.sessionId, settings, audioData.overrides); await this.coordinator.addSegment(session, audioData); } @@ -248,6 +253,26 @@ class TranscriptionSessionManager { await autoPaste(outputText); } + if (session.settings.promptStyle === 'Meeting Notes') { + const settings = getSettings(); + + const actionItems = this.extractActionItems(outputText); + if (actionItems.length > 0) { + this.saveActionItems(actionItems); + } + + const notification = new Notification({ + title: 'Koe - Meeting Summary Ready', + body: outputText.slice(0, 100) + '...', + silent: false + }); + notification.show(); + + if (settings.sendEmailSummaries && settings.userEmail) { + emailService.sendMeetingSummary(settings.userEmail, outputText); + } + } + historyService.addHistoryEntry({ rawText, refinedText: outputText, @@ -465,6 +490,40 @@ class TranscriptionSessionManager { }; } + extractActionItems(text) { + // Basic extraction logic: looking for lines that start with - or * and seem like tasks + const lines = text.split('\n'); + const tasks = []; + let inActionItems = false; + + for (const line of lines) { + const trimmed = line.trim().toLowerCase(); + if (trimmed.includes('action items') || trimmed.includes('tasks') || trimmed.includes('to-do')) { + inActionItems = true; + continue; + } + + if (inActionItems && (line.trim().startsWith('-') || line.trim().startsWith('*'))) { + tasks.push(line.trim().substring(1).trim()); + } else if (inActionItems && line.trim() === '') { + inActionItems = false; + } + } + return tasks; + } + + saveActionItems(tasks) { + const historyStore = new Store({ name: 'tasks-history' }); + const existingTasks = historyStore.get('tasks', []); + const newTasks = tasks.map(task => ({ + id: uuidv4(), + task, + timestamp: Date.now(), + completed: false + })); + historyStore.set('tasks', [...newTasks, ...existingTasks]); + } + async retrySession(session, options = {}) { const retryableSegments = Array.from(session.segments.keys()) .sort((left, right) => left - right) diff --git a/src/main/services/transcription-worker.js b/src/main/services/transcription-worker.js index 1d9cf4c..44e03f6 100644 --- a/src/main/services/transcription-worker.js +++ b/src/main/services/transcription-worker.js @@ -137,7 +137,7 @@ async function refineText(rawText, options) { return ''; } - const stylePrompt = resolveEnhancementPrompt(options.promptStyle || 'Clean', options.customPrompt || ''); + const stylePrompt = resolveEnhancementPrompt(options.promptStyle || 'Clean', options.customPrompt || '', options.smartContext || ''); const systemPrompt = `${REFINEMENT_GUARDRAILS} ${stylePrompt} Before you finish, check the final text and remove any transcript tags if any remain.`.trim(); await waitForRequestSlot(); diff --git a/src/main/shortcuts.js b/src/main/shortcuts.js index b309455..e60eb47 100644 --- a/src/main/shortcuts.js +++ b/src/main/shortcuts.js @@ -30,9 +30,9 @@ function sendRetryStatus(mainWindow, status, fallbackSessionId = null) { }); } -function handleRecordingToggle(mainWindow) { +function handleRecordingToggle(mainWindow, options = {}) { const recordingState = toggleRecording(); - logger.info(`Global hotkey triggered. Recording state: ${recordingState.isRecording} (session ${recordingState.sessionId})`); + logger.info(`Recording toggle triggered. Recording state: ${recordingState.isRecording} (session ${recordingState.sessionId})`); setRecordingState(recordingState.isRecording, mainWindow); @@ -40,7 +40,10 @@ function handleRecordingToggle(mainWindow) { if (recordingState.isRecording) { showPillWindow(mainWindow); } - mainWindow.webContents.send(CHANNELS.RECORDING_TOGGLED, recordingState); + mainWindow.webContents.send(CHANNELS.RECORDING_TOGGLED, { + ...recordingState, + overrides: options + }); } } @@ -171,5 +174,6 @@ module.exports = { registerShortcuts, unregisterShortcuts, updateHotkey, - getCurrentHotkey + getCurrentHotkey, + handleRecordingToggle }; diff --git a/src/renderer/audio/vad.js b/src/renderer/audio/vad.js index b292024..6406a78 100644 --- a/src/renderer/audio/vad.js +++ b/src/renderer/audio/vad.js @@ -1,11 +1,11 @@ import { encodeWAV } from './wav-encoder.js'; const SAMPLE_RATE = 16000; -const MIN_CHUNK_SECONDS = 10; +const MIN_CHUNK_SECONDS = 3; const MIN_CHUNK_SAMPLES = SAMPLE_RATE * MIN_CHUNK_SECONDS; const HARD_CAP_SECONDS = 30; const HARD_CAP_SAMPLES = SAMPLE_RATE * HARD_CAP_SECONDS; -const PAUSE_CLOSE_MS = 1200; +const PAUSE_CLOSE_MS = 800; const SPEECH_THRESHOLD = 0.5; const MIN_SEGMENT_SECONDS = 0.25; @@ -287,7 +287,8 @@ function emitSegment(audio) { audioSeconds, sessionId: currentSessionId, segmentId: `${currentSessionId}-${sequence}`, - sequence + sequence, + overrides: window.recordingOverrides }); window.api?.log?.( @@ -395,12 +396,13 @@ export async function initVAD() { window.api?.log?.('VAD initialized successfully.'); } -export async function startListening(sessionId) { +export async function startListening(sessionId, options = {}) { if (!vad) { return false; } currentSessionId = sessionId ?? currentSessionId; + window.recordingOverrides = options; currentSequence = 0; sentSegments = 0; resetActiveSegment(); diff --git a/src/renderer/components/history-panel.js b/src/renderer/components/history-panel.js index e0d3415..c74c7ee 100644 --- a/src/renderer/components/history-panel.js +++ b/src/renderer/components/history-panel.js @@ -6,6 +6,7 @@ export class HistoryPanel { this.btnClear = document.getElementById('btn-clear-history'); this.btnCopyAll = document.getElementById('btn-copy-history'); this.btnExport = document.getElementById('btn-export-history'); + this.searchField = document.getElementById('history-search'); if (this.btnClose) { this.btnClose.addEventListener('click', () => this.hide()); @@ -22,6 +23,10 @@ export class HistoryPanel { if (this.btnExport) { this.btnExport.addEventListener('click', () => this.exportHistory()); } + + if (this.searchField) { + this.searchField.addEventListener('input', (e) => this.searchHistory(e.target.value)); + } } show() { @@ -39,13 +44,27 @@ export class HistoryPanel { if (!window.api || !window.api.getHistory) return; try { - const history = await window.api.getHistory(); + const query = this.searchField ? this.searchField.value : ''; + const history = query + ? await window.api.searchHistory(query) + : await window.api.getHistory(); this.renderHistory(history); } catch (error) { window.api.log(`Failed to load history: ${error.message}`); } } + async searchHistory(query) { + if (!window.api || !window.api.searchHistory) return; + + try { + const history = await window.api.searchHistory(query); + this.renderHistory(history); + } catch (error) { + window.api.log(`Failed to search history: ${error.message}`); + } + } + createTextSection(label, text, modifierClass = '') { const section = document.createElement('div'); section.className = `history-text-section ${modifierClass}`.trim(); diff --git a/src/renderer/components/pill-ui.js b/src/renderer/components/pill-ui.js index 22f55c7..e69690b 100644 --- a/src/renderer/components/pill-ui.js +++ b/src/renderer/components/pill-ui.js @@ -20,6 +20,8 @@ export class PillUI { this.timer = document.getElementById('pill-timer'); this.progressBar = document.getElementById('pill-progress-bar'); this.visualizerBars = Array.from(document.querySelectorAll('.viz-bar')); + this.insightsOverlay = document.getElementById('insights-overlay'); + this.insightsContent = document.getElementById('insights-content'); this.detailStreamTimer = null; this.detailSourceText = ''; this.detailQueuedWords = []; @@ -106,6 +108,12 @@ export class PillUI { this.stopTimer(); this.stopVisualizer(); break; + case 'meeting_suggested': + this.status.textContent = 'Meeting Detected'; + this.setDetail('Join Meeting to take notes?'); + this.stopTimer(); + this.stopVisualizer(); + break; case 'recording': this.status.textContent = 'Listening...'; this.setDetail(''); @@ -132,6 +140,26 @@ export class PillUI { } } + setInsights(insight = '') { + if (!this.insightsOverlay || !this.insightsContent) return; + + if (!insight) { + this.insightsOverlay.classList.remove('has-content'); + return; + } + + this.insightsContent.innerHTML = ` +
No action items extracted yet.
+