'use strict'; const DEFAULT_AI_CONFIG = { provider: 'openai', model: 'gpt-4o-mini' }; const DEFAULT_CAPTURE_MODE = 'tab'; const DEFAULT_LISTENING_PROMPT = 'You are a helpful assistant that answers questions briefly and concisely during interviews. Provide clear, professional responses.'; const CONTEXT_PROFILES_STORAGE_KEY = 'contextProfiles'; const ACTIVE_CONTEXT_PROFILE_STORAGE_KEY = 'activeContextProfileId'; const CONTEXTS_BY_PROFILE_STORAGE_KEY = 'contextsByProfile'; const DEFAULT_CONTEXT_PROFILE_ID = 'interview_software'; const DEFAULT_CONTEXT_PROFILES = [ { id: 'interview_software', name: 'Interview (Software Development)', mode: 'interview', systemPrompt: 'You are an interview assistant for software development. Keep responses concise, technically correct, and structured.' }, { id: 'meeting_standup', name: 'Meeting (Daily Standup)', mode: 'standup', systemPrompt: 'You are a standup meeting assistant. Focus on updates, blockers, owners, and next steps.' }, { id: 'meeting_sales', name: 'Meeting (Sales Call)', mode: 'meeting', systemPrompt: 'You are a sales call assistant. Focus on customer needs, objections, commitments, and clear follow-up actions.' } ]; const DEFAULT_MODE_POLICIES = { interview: { systemPrompt: 'You are an interview assistant. Prioritize concise, high-signal answers tailored to technical interviews.', maxGeneralItems: 4, maxSystemItems: 2 }, meeting: { systemPrompt: 'You are a meeting assistant. Prioritize clarity, decisions, risks, and concrete next steps.', maxGeneralItems: 5, maxSystemItems: 2 }, standup: { systemPrompt: 'You are a standup assistant. Keep updates concise and action-oriented.', maxGeneralItems: 4, maxSystemItems: 2 }, custom: { systemPrompt: DEFAULT_LISTENING_PROMPT, maxGeneralItems: 4, maxSystemItems: 2 } }; const MEMORY_STORAGE_KEY = 'memoryStore'; const MEMORY_SCHEMA_VERSION = 1; const SESSION_STATUS = { IDLE: 'idle', ACTIVE: 'active', PAUSED: 'paused', ENDED: 'ended' }; const RAG_MIN_SCORE = 0.05; const RAG_MAX_ITEMS = 3; const STANDUP_PROMPT = `You are an assistant that produces daily standup summaries.\nReturn JSON only with keys:\nsummary (string), action_items (array of {text, assignee?}), blockers (array of strings), decisions (array of strings).\nKeep summary concise and action items clear.`; const TRANSCRIPT_NOISE_PATTERNS = [ /^:\w+:$/i, /^click to react$/i, /^add reaction$/i, /^reply$/i, /^forward$/i, /^more$/i, /^message\s+#/i ]; const createDefaultMemoryStore = () => ({ version: MEMORY_SCHEMA_VERSION, profile: { name: '', role: '', notes: '', updatedAt: null }, sessions: [], summaries: [], actionItems: [] }); const AI_SERVICES = { openai: { baseUrl: 'https://api.openai.com/v1/chat/completions', headers: (apiKey) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }), formatRequest: (model, question, context = '', options = {}) => ({ model, messages: [ { role: 'system', content: `${options.systemPrompt || DEFAULT_LISTENING_PROMPT}${context ? `\n\nContext Information:\n${context}` : ''}` }, { role: 'user', content: question } ], max_tokens: options.maxTokens || 200, temperature: options.temperature ?? 0.7 }), parseResponse: (data) => data.choices[0].message.content.trim() }, anthropic: { baseUrl: 'https://api.anthropic.com/v1/messages', headers: (apiKey) => ({ 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }), formatRequest: (model, question, context = '', options = {}) => ({ model, max_tokens: options.maxTokens || 200, messages: [ { role: 'user', content: `${options.systemPrompt || DEFAULT_LISTENING_PROMPT}${context ? `\n\nContext Information:\n${context}` : ''}\n\nQuestion: ${question}` } ] }), parseResponse: (data) => data.content[0].text.trim() }, google: { baseUrl: (apiKey, model) => `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, headers: () => ({ 'Content-Type': 'application/json' }), formatRequest: (model, question, context = '', options = {}) => ({ systemInstruction: { role: 'system', parts: [ { text: `${options.systemPrompt || DEFAULT_LISTENING_PROMPT}${context ? `\n\nContext Information:\n${context}` : ''}` } ] }, contents: [ { role: 'user', parts: [{ text: `Question: ${question}` }] } ], generationConfig: { maxOutputTokens: options.maxTokens || 200, temperature: options.temperature ?? 0.7 } }), parseResponse: (data) => data.candidates[0].content.parts[0].text.trim() }, deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', headers: (apiKey) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }), formatRequest: (model, question, context = '', options = {}) => ({ model, messages: [ { role: 'system', content: `${options.systemPrompt || DEFAULT_LISTENING_PROMPT}${context ? `\n\nContext Information:\n${context}` : ''}` }, { role: 'user', content: question } ], max_tokens: options.maxTokens || 200, temperature: options.temperature ?? 0.7 }), parseResponse: (data) => data.choices[0].message.content.trim() }, ollama: { baseUrl: 'http://localhost:11434/api/generate', headers: () => ({ 'Content-Type': 'application/json' }), formatRequest: (model, question, context = '', options = {}) => ({ model, prompt: `${options.systemPrompt || DEFAULT_LISTENING_PROMPT}${context ? `\n\nContext Information:\n${context}` : ''}\n\nQuestion: ${question}\n\nAnswer:`, stream: false, options: { temperature: options.temperature ?? 0.7, num_predict: options.maxTokens || 200 } }), parseResponse: (data) => data.response.trim() } }; const state = { recognition: undefined, assistantWindowId: null, currentAIConfig: { ...DEFAULT_AI_CONFIG }, currentCaptureMode: DEFAULT_CAPTURE_MODE, remoteServer: null, remoteServerPort: null, activeConnections: new Set(), currentSessionId: null, currentSessionStatus: SESSION_STATUS.IDLE, currentSessionConsent: false, pendingSessionConsent: null, lastSessionId: null, mcpInitialized: false, pendingAutomation: null, activeContextProfileId: DEFAULT_CONTEXT_PROFILE_ID, activeAutomationId: null, sttSessionLanguage: '' }; chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return handleMessage(request, sender, sendResponse); }); chrome.action.onClicked.addListener((tab) => { chrome.sidePanel.open({ tabId: tab.id }); }); chrome.windows.onRemoved.addListener((windowId) => { if (windowId === state.assistantWindowId) { state.assistantWindowId = null; } }); initializeActiveState(); initializeMemoryStore(); initializeContextProfiles(); function handleMessage(request, _sender, sendResponse) { switch (request.action) { case 'startListening': if (request.aiProvider && request.model) { state.currentAIConfig = { provider: request.aiProvider, model: request.model }; } if (request.captureMode) { state.currentCaptureMode = request.captureMode; } if (request.contextProfileId) { state.activeContextProfileId = request.contextProfileId; chrome.storage.sync.set({ [ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]: request.contextProfileId }); } state.activeAutomationId = request.automationId || null; startListening(); return false; case 'stopListening': stopListening(); return false; case 'pauseListening': pauseListening(); return false; case 'resumeListening': startListening(); return false; case 'getAIResponse': getAIResponse(request.question); return false; case 'transcribeAudioChunk': transcribeAudioChunk(request.audioBase64, request.mimeType, request.captureMode) .then((result) => sendResponse(result)) .catch((error) => sendResponse({ success: false, error: error.message })); return true; case 'startRemoteServer': startRemoteServer(request.sessionId, request.port, sendResponse); return true; case 'stopRemoteServer': stopRemoteServer(sendResponse); return true; case 'remoteQuestion': getAIResponse(request.question); return false; case 'grantTabAccess': grantTabAccess(sendResponse); return true; case 'openAssistantWindow': openAssistantWindow(sendResponse); return true; case 'openSettingsWindow': openSettingsWindow(sendResponse); return true; case 'mcp:listTools': listMcpTools(sendResponse); return true; case 'mcp:callTool': callMcpTool(request.toolName, request.args, sendResponse); return true; case 'stt:testConnection': testSttConnection() .then((result) => sendResponse(result)) .catch((error) => sendResponse({ success: false, error: error.message })); return true; case 'automation:run': runAutomation(request.trigger || 'manual', request.automationId || null, { testMode: Boolean(request.testMode) }, sendResponse); return true; case 'automation:list': getAutomationsWithMigration() .then((automations) => sendResponse({ success: true, automations })) .catch((error) => sendResponse({ success: false, error: error.message })); return true; case 'automation:approve': approveAutomation(sendResponse); return true; case 'automation:reject': rejectAutomation(sendResponse); return true; case 'updateTranscript': appendTranscriptToCurrentSession(request.transcript); return false; case 'session:setConsent': setCurrentSessionConsent(Boolean(request.consent)); return false; case 'session:forgetCurrent': forgetCurrentSession(); return false; case 'session:getState': sendResponse({ sessionId: state.currentSessionId, status: state.currentSessionStatus, consent: state.currentSessionConsent, lastSessionId: state.lastSessionId }); return true; case 'session:saveSummary': saveCurrentSessionSummary(request.content || '', Boolean(request.saveToMemory), request.sessionId).then((result) => sendResponse(result) ); return true; case 'memory:get': getMemoryStore().then((store) => sendResponse({ success: true, store })); return true; case 'memory:updateProfile': updateMemoryProfile(request.profile || {}).then((store) => sendResponse({ success: true, store })); return true; case 'memory:addSession': addMemorySession(request.session || {}).then((session) => sendResponse({ success: true, session })); return true; case 'memory:addSummary': addMemorySummary(request.summary || {}).then((summary) => sendResponse({ success: true, summary })); return true; case 'memory:addActionItems': addMemoryActionItems(request.items || [], request.sessionId).then((items) => sendResponse({ success: true, items }) ); return true; case 'memory:clear': clearMemoryStore().then((store) => sendResponse({ success: true, store })); return true; default: return false; } } function startListening() { state.sttSessionLanguage = ''; ensureActiveSession(); runAutomation('sessionStart', state.activeAutomationId, { testMode: false }, () => {}); if (state.currentCaptureMode === 'mic') { startMicListening(); return; } if (state.currentCaptureMode === 'mixed') { startMixedListening(); return; } chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (chrome.runtime.lastError) { console.error('Error querying tabs:', chrome.runtime.lastError); return; } if (!tabs.length) { console.error('No active tab found'); return; } const tab = tabs[0]; if (!isValidCaptureTab(tab)) { const message = 'Error: Cannot capture audio from this page. Please navigate to a regular website.'; console.error('Cannot capture audio from this type of page:', tab.url); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: message }); return; } chrome.tabCapture.getMediaStreamId({ consumerTabId: tab.id }, (streamId) => { if (chrome.runtime.lastError) { const errorMsg = chrome.runtime.lastError.message || 'Unknown error'; const userMessage = buildTabCaptureErrorMessage(errorMsg); console.error('Error getting media stream ID:', chrome.runtime.lastError); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: userMessage }); return; } if (!streamId) { console.error('No stream ID received'); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Failed to get media stream. Please try again.' }); return; } injectContentScriptAndStartCapture(tab.id, streamId); }); }); } function startMicListening() { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (chrome.runtime.lastError || tabs.length === 0) { console.error('Error querying tabs:', chrome.runtime.lastError); return; } const tab = tabs[0]; if (!isValidCaptureTab(tab)) { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Cannot capture audio from this page. Please navigate to a regular website.' }); return; } chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] }, () => { if (chrome.runtime.lastError) { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Failed to inject content script. Please refresh the page and try again.' }); return; } chrome.tabs.sendMessage(tab.id, { action: 'startMicCapture' }, () => { if (chrome.runtime.lastError) { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Failed to start microphone capture.' }); } else { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Listening for audio (mic-only)...' }); } }); }); }); } function startMixedListening() { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (chrome.runtime.lastError || tabs.length === 0) { console.error('Error querying tabs:', chrome.runtime.lastError); return; } const tab = tabs[0]; if (!isValidCaptureTab(tab)) { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Cannot capture audio from this page. Please navigate to a regular website.' }); return; } chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] }, () => { if (chrome.runtime.lastError) { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Failed to inject content script. Please refresh the page and try again.' }); return; } chrome.tabs.sendMessage(tab.id, { action: 'startMixedCapture' }, () => { if (chrome.runtime.lastError) { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Failed to start mixed capture.' }); } else { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Listening for audio (mixed mode)...' }); } }); }); }); } function injectContentScriptAndStartCapture(tabId, streamId) { chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }, () => { if (chrome.runtime.lastError) { console.error('Error injecting content script:', chrome.runtime.lastError); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Error: Failed to inject content script. Please refresh the page and try again.' }); return; } setTimeout(() => { chrome.tabs.sendMessage(tabId, { action: 'startCapture', streamId }, () => { if (chrome.runtime.lastError) { const errorMsg = chrome.runtime.lastError.message || 'Unknown error'; console.error('Error starting capture:', chrome.runtime.lastError); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: `Error: ${errorMsg}. Please make sure microphone permissions are granted.` }); } else { console.log('Capture started successfully'); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Listening for audio... Speak your questions!' }); } }); }, 200); }); } function stopListening() { endCurrentSession(); runAutomation('sessionEnd', state.activeAutomationId, { testMode: false }, () => {}); chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (chrome.runtime.lastError || tabs.length === 0) { console.error('Error querying tabs for stop:', chrome.runtime.lastError); return; } chrome.tabs.sendMessage(tabs[0].id, { action: 'stopCapture' }, () => { if (chrome.runtime.lastError) { console.error('Error stopping capture:', chrome.runtime.lastError); return; } console.log('Capture stopped successfully'); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Stopped listening.' }); }); }); } function pauseListening() { pauseCurrentSession(); chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (chrome.runtime.lastError || tabs.length === 0) { console.error('Error querying tabs for pause:', chrome.runtime.lastError); return; } chrome.tabs.sendMessage(tabs[0].id, { action: 'stopCapture' }, () => { if (chrome.runtime.lastError) { console.error('Error pausing capture:', chrome.runtime.lastError); return; } chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Paused listening.' }); }); }); } function isQuestion(text) { const questionWords = ['what', 'when', 'where', 'who', 'why', 'how']; const lowerText = text.toLowerCase(); return questionWords.some((word) => lowerText.includes(word)) || text.includes('?'); } async function getAIResponse(question) { try { const storedConfig = await getAIConfigFromStorage(); if (storedConfig) { state.currentAIConfig = storedConfig; } const { provider, model } = state.currentAIConfig; const service = AI_SERVICES[provider]; const speedMode = await getSpeedModeFromStorage(); if (!service) { throw new Error(`Unsupported AI provider: ${provider}`); } const activeProfile = await getActiveContextProfile(); const modePolicy = getModePolicy(activeProfile); const contextData = await getStoredContexts(activeProfile.id); const { systemContexts, generalContexts } = selectContextsForRequest(contextData, speedMode, modePolicy); const memoryStore = await getMemoryStore(); const memoryContext = buildMemoryContext(question, memoryStore, speedMode); const systemPromptExtra = systemContexts.length ? systemContexts.map((ctx) => `${ctx.title}:\n${ctx.content}`).join('\n\n---\n\n') : ''; const contextString = generalContexts.length ? generalContexts.map((ctx) => `${ctx.title}:\n${ctx.content}`).join('\n\n---\n\n') : ''; let apiKey = null; if (provider !== 'ollama') { apiKey = await getApiKey(provider); if (!apiKey) { throw new Error(`${provider.charAt(0).toUpperCase() + provider.slice(1)} API key not set`); } } console.log(`Sending request to ${provider} API (${model})...`); let url; let headers; if (provider === 'google') { url = service.baseUrl(apiKey, model); headers = service.headers(); } else { url = service.baseUrl; headers = service.headers(apiKey); } const mergedContextRaw = systemPromptExtra ? `${systemPromptExtra}${contextString ? `\n\n---\n\n${contextString}` : ''}${memoryContext ? `\n\n---\n\n${memoryContext}` : ''}` : `${contextString}${memoryContext ? `\n\n---\n\n${memoryContext}` : ''}`; const mergedContext = truncateContext(mergedContextRaw, provider, speedMode); const requestOptions = buildRequestOptions(speedMode, modePolicy); const body = JSON.stringify(service.formatRequest(model, question, mergedContext, requestOptions)); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), speedMode ? 20000 : 30000); const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); let errorMessage; try { const errorData = JSON.parse(errorText); errorMessage = errorData.error?.message || errorData.message || errorText; } catch { errorMessage = errorText; } throw new Error(`Failed to get response from ${provider}: ${response.status} ${response.statusText}\n${errorMessage}`); } const data = await response.json(); const answer = normalizeGeneratedText(service.parseResponse(data)); chrome.runtime.sendMessage({ action: 'updateAIResponse', response: answer }); broadcastToRemoteDevices('aiResponse', { response: answer, question }); } catch (error) { console.error('Error getting AI response:', error); let errorMessage = error.message; if (error.message.includes('API key')) { errorMessage = `${error.message}. Please check your API key in the settings.`; } else if (error.message.includes('Failed to fetch')) { if (state.currentAIConfig.provider === 'ollama') { errorMessage = 'Failed to connect to Ollama. Make sure Ollama is running locally on port 11434.'; } else { errorMessage = 'Network error. Please check your internet connection.'; } } else if (error.message.includes('aborted')) { errorMessage = 'Request timed out. Try again or enable speed mode.'; } const fullErrorMessage = `Error: ${errorMessage}`; chrome.runtime.sendMessage({ action: 'updateAIResponse', response: fullErrorMessage }); broadcastToRemoteDevices('aiResponse', { response: fullErrorMessage, question }); } } function truncateContext(context, provider, speedMode) { if (!context) return ''; const maxContextCharsByProvider = { deepseek: speedMode ? 30000 : 60000, openai: speedMode ? 50000 : 120000, anthropic: speedMode ? 50000 : 120000, google: speedMode ? 50000 : 120000, ollama: speedMode ? 50000 : 120000 }; const maxChars = maxContextCharsByProvider[provider] || 200000; if (context.length <= maxChars) return context; return `${context.slice(0, maxChars)}\n\n[Context truncated to fit model limits.]`; } function selectContextsForRequest(contexts, speedMode, modePolicy = DEFAULT_MODE_POLICIES.custom) { const sorted = [...contexts].sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')); const systemContexts = sorted.filter((ctx) => ctx.type === 'system'); const generalContexts = sorted.filter((ctx) => ctx.type !== 'system'); const baseGeneralItems = Number(modePolicy.maxGeneralItems) || 4; const baseSystemItems = Number(modePolicy.maxSystemItems) || 2; const maxGeneralItems = speedMode ? Math.max(1, baseGeneralItems - 2) : baseGeneralItems; const maxSystemItems = speedMode ? 1 : baseSystemItems; const maxItemChars = speedMode ? 4000 : 8000; const trimItem = (ctx) => ({ ...ctx, content: (ctx.content || '').slice(0, maxItemChars) }); return { systemContexts: systemContexts.slice(0, maxSystemItems).map(trimItem), generalContexts: generalContexts.slice(0, maxGeneralItems).map(trimItem) }; } function buildRequestOptions(speedMode, modePolicy = DEFAULT_MODE_POLICIES.custom) { if (!speedMode) { return { maxTokens: 200, temperature: 0.7, systemPrompt: modePolicy.systemPrompt || DEFAULT_LISTENING_PROMPT }; } return { maxTokens: 120, temperature: 0.4, systemPrompt: modePolicy.systemPrompt || DEFAULT_LISTENING_PROMPT }; } function normalizeGeneratedText(text) { if (typeof text !== 'string') return text; return text .replace(/\r\n/g, '\n') .replace(/\\r\\n/g, '\n') .replace(/\\n/g, '\n') .replace(/\\t/g, '\t'); } function buildStandupRequest(provider, model, transcriptText, options, apiKey) { switch (provider) { case 'openai': case 'deepseek': return { model, messages: [ { role: 'system', content: STANDUP_PROMPT }, { role: 'user', content: transcriptText } ], max_tokens: options.maxTokens || 200, temperature: options.temperature ?? 0.4 }; case 'anthropic': return { model, max_tokens: options.maxTokens || 200, messages: [ { role: 'user', content: `${STANDUP_PROMPT}\n\nTranscript:\n${transcriptText}` } ] }; case 'google': return { systemInstruction: { role: 'system', parts: [{ text: STANDUP_PROMPT }] }, contents: [ { role: 'user', parts: [{ text: transcriptText }] } ], generationConfig: { maxOutputTokens: options.maxTokens || 200, temperature: options.temperature ?? 0.4 } }; case 'ollama': return { model, prompt: `${STANDUP_PROMPT}\n\nTranscript:\n${transcriptText}\n\nJSON:`, stream: false, options: { temperature: options.temperature ?? 0.4, num_predict: options.maxTokens || 200 } }; default: return AI_SERVICES.openai.formatRequest(model, transcriptText, STANDUP_PROMPT, options); } } function getSpeedModeFromStorage() { return new Promise((resolve) => { chrome.storage.sync.get(['speedMode'], (result) => { if (chrome.runtime.lastError) { resolve(false); return; } resolve(Boolean(result.speedMode)); }); }); } function grantTabAccess(sendResponse) { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (chrome.runtime.lastError || !tabs.length) { sendResponse({ success: false, error: 'No active tab found.' }); return; } const tabId = tabs[0].id; chrome.sidePanel.open({ tabId }, () => { if (chrome.runtime.lastError) { sendResponse({ success: false, error: 'Click the extension icon on the target tab to grant access.' }); return; } if (chrome.action && chrome.action.openPopup) { chrome.action.openPopup(() => { sendResponse({ success: true }); }); } else { sendResponse({ success: true }); } }); }); } function openAssistantWindow(sendResponse) { if (state.assistantWindowId !== null) { chrome.windows.update(state.assistantWindowId, { focused: true }, () => { sendResponse({ success: true }); }); return; } chrome.windows.create( { url: chrome.runtime.getURL('assistant.html'), type: 'popup', width: 420, height: 320 }, (win) => { if (chrome.runtime.lastError || !win) { sendResponse({ success: false, error: 'Failed to open assistant window.' }); return; } state.assistantWindowId = win.id; sendResponse({ success: true }); } ); } function openSettingsWindow(sendResponse) { chrome.windows.create( { url: chrome.runtime.getURL('settings.html'), type: 'popup', width: 900, height: 720 }, (win) => { if (chrome.runtime.lastError || !win) { sendResponse({ success: false, error: 'Failed to open settings window.' }); return; } sendResponse({ success: true }); } ); } function getAdvancedSettings() { return new Promise((resolve) => { chrome.storage.sync.get(['advancedSettings'], (result) => { resolve(result.advancedSettings || {}); }); }); } function mcpRequest(serverUrl, apiKey, method, params = {}) { const body = { jsonrpc: '2.0', id: Date.now(), method, params }; const headers = { 'Content-Type': 'application/json' }; if (apiKey) { headers['x-mcp-api-key'] = apiKey; headers.Authorization = `Bearer ${apiKey}`; } return fetch(serverUrl, { method: 'POST', headers, body: JSON.stringify(body) }).then(async (response) => { const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error?.message || `MCP request failed (${response.status}).`); } if (data.error) { throw new Error(data.error.message || 'MCP error.'); } return data.result; }); } async function ensureMcpInitialized(serverUrl, apiKey) { if (state.mcpInitialized) return; try { await mcpRequest(serverUrl, apiKey, 'initialize', { clientInfo: { name: 'AI Assistant', version: '1.0' } }); state.mcpInitialized = true; } catch (error) { // MCP servers may not require initialize; ignore failures. } } async function listMcpTools(sendResponse) { try { const { mcpServerUrl, mcpApiKey } = await getAdvancedSettings(); if (!mcpServerUrl) { sendResponse({ success: false, error: 'MCP server URL is not set.' }); return; } const endpoint = resolveMcpEndpoint(mcpServerUrl); await ensureMcpInitialized(endpoint, mcpApiKey); const result = await mcpRequest(endpoint, mcpApiKey, 'tools/list'); sendResponse({ success: true, tools: result?.tools || [] }); } catch (error) { sendResponse({ success: false, error: error.message || 'Failed to list tools.' }); } } async function callMcpTool(toolName, args, sendResponse) { try { const { mcpServerUrl, mcpApiKey } = await getAdvancedSettings(); if (!mcpServerUrl) { sendResponse({ success: false, error: 'MCP server URL is not set.' }); return; } if (!toolName) { sendResponse({ success: false, error: 'Select a tool first.' }); return; } const endpoint = resolveMcpEndpoint(mcpServerUrl); await ensureMcpInitialized(endpoint, mcpApiKey); const result = await mcpRequest(endpoint, mcpApiKey, 'tools/call', { name: toolName, arguments: args || {} }); sendResponse({ success: true, result }); } catch (error) { sendResponse({ success: false, error: error.message || 'Failed to call tool.' }); } } async function runAutomation(trigger, automationId, options, sendResponse) { try { const runOptions = options || { testMode: false }; const automations = await getAutomationsWithMigration(); const eligible = automations.filter((automation) => { if (automationId && automation.id !== automationId) return false; if (!automation.enabled) return false; const triggers = automation.triggers || {}; return Boolean(triggers[trigger]); }); if (!eligible.length) { sendResponse({ success: false, error: 'No automations match this trigger.' }); return; } const results = []; for (const automation of eligible) { if (automation.requireApproval && trigger !== 'manual' && !runOptions.testMode) { if (state.pendingAutomation) continue; state.pendingAutomation = { trigger, automation, options: runOptions }; chrome.runtime.sendMessage({ action: 'automation:needsApproval', trigger, automationName: automation.name || 'Automation', actions: describeAutomationTargets(automation) }); continue; } const automationResult = await runAutomationByType(automation, runOptions); results.push({ automationId: automation.id, ...automationResult }); } sendResponse({ success: true, results }); } catch (error) { sendResponse({ success: false, error: error.message || 'Automation failed.' }); } } async function runStandupAutomationFor(automation, options) { const standup = automation.standup || {}; let aiResult; let summaryText; let session = null; const now = new Date(); const dateIso = now.toISOString().slice(0, 10); const timeIso = now.toISOString().slice(11, 19); const dateCompact = dateIso.replace(/-/g, ''); const dateTimeIso = `${dateIso}_${timeIso.replace(/:/g, '-')}`; const weekday = now.toLocaleDateString('en-US', { weekday: 'long' }); const monthName = now.toLocaleDateString('en-US', { month: 'long' }); const humanDate = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); if (options?.testMode) { aiResult = buildStandupTestResult(); summaryText = formatStandupText(aiResult, humanDate); } else { session = await getLatestSessionWithTranscript(); if (!session || !session.transcript || session.transcript.length === 0) { throw new Error('No transcript available for standup summary.'); } const transcriptText = session.transcript.map((entry) => entry.text).join('\n'); aiResult = await generateStandupSummary(transcriptText); summaryText = formatStandupText(aiResult, humanDate); } if (session && session.storeConsent) { await addMemorySummary({ sessionId: session.id, content: summaryText }); } const templateContext = { summary: summaryText, summary_brief: aiResult.summary || '', summary_full: summaryText, summary_json: JSON.stringify(aiResult, null, 2), action_items: (aiResult.action_items || []).map((item) => `- ${item.text}${item.assignee ? ` (owner: ${item.assignee})` : ''}`).join('\n'), action_items_json: JSON.stringify(aiResult.action_items || [], null, 2), blockers: (aiResult.blockers || []).map((item) => `- ${item}`).join('\n'), decisions: (aiResult.decisions || []).map((item) => `- ${item}`).join('\n'), date: dateIso, date_compact: dateCompact, datetime: dateTimeIso, time: timeIso, weekday, month: monthName, date_human: humanDate }; const results = []; if (standup.discordToolName && standup.discordArgsTemplate) { const args = buildTemplateArgs(standup.discordArgsTemplate, templateContext); const result = await callMcpToolInternal(standup.discordToolName, args); results.push({ target: 'discord', success: true, result }); } if (standup.nextcloudToolName) { const nextcloudTemplate = standup.nextcloudArgsTemplate || buildDefaultNextcloudTemplate(); const args = buildTemplateArgs(nextcloudTemplate, templateContext); const result = await callMcpToolInternal(standup.nextcloudToolName, args); results.push({ target: 'nextcloud', success: true, result }); } return { summary: aiResult, results }; } function buildStandupTestResult() { return { summary: 'Test standup summary: Worked on automation UX, reviewed MCP integration, and fixed styling issues.', action_items: [ { text: 'Post standup summary to Discord', assignee: 'Automation' }, { text: 'Save notes to Nextcloud', assignee: 'Automation' } ], blockers: [], decisions: ['Proceed with automation manager list + editor layout.'] }; } async function getLatestSessionWithTranscript() { const store = await getMemoryStore(); const sessions = Array.isArray(store.sessions) ? store.sessions : []; if (!sessions.length) return null; const sorted = [...sessions].sort((a, b) => (b.startedAt || '').localeCompare(a.startedAt || '')); return sorted.find((session) => Array.isArray(session.transcript) && session.transcript.length > 0) || sorted[0]; } async function generateStandupSummary(transcriptText) { const storedConfig = await getAIConfigFromStorage(); if (storedConfig) { state.currentAIConfig = storedConfig; } const { provider, model } = state.currentAIConfig; const service = AI_SERVICES[provider]; if (!service) { throw new Error(`Unsupported AI provider: ${provider}`); } let apiKey = null; if (provider !== 'ollama') { apiKey = await getApiKey(provider); if (!apiKey) { throw new Error(`${provider.charAt(0).toUpperCase() + provider.slice(1)} API key not set`); } } let url; let headers; if (provider === 'google') { url = service.baseUrl(apiKey, model); headers = service.headers(); } else { url = service.baseUrl; headers = service.headers(apiKey); } const sanitizedTranscript = sanitizeTranscriptForSummary(transcriptText); const requestOptions = { ...buildRequestOptions(false), maxTokens: 600, temperature: 0.2 }; const body = JSON.stringify(buildStandupRequest(provider, model, sanitizedTranscript, requestOptions, apiKey)); const response = await fetch(url, { method: 'POST', headers, body }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Standup summary failed: ${errorText}`); } const data = await response.json(); const raw = normalizeGeneratedText(service.parseResponse(data)); const parsed = parseJsonFromText(raw); if (!parsed) { return { summary: sanitizeSummaryField(normalizeGeneratedText(raw).trim()), action_items: [], blockers: [], decisions: [] }; } const normalized = normalizeStandupResult(parsed); return normalized; } function sanitizeTranscriptForSummary(transcriptText) { if (!transcriptText) return ''; const lines = transcriptText .split('\n') .map((line) => normalizeGeneratedText(line).trim()) .filter(Boolean) .filter((line) => !TRANSCRIPT_NOISE_PATTERNS.some((pattern) => pattern.test(line))); return lines.join('\n').trim(); } function sanitizeSummaryField(text) { if (!text) return ''; return String(text) .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]{2,}/g, ' ') .trim(); } function normalizeStandupResult(parsed) { const summary = sanitizeSummaryField(normalizeGeneratedText(parsed.summary || '')); const actionItems = Array.isArray(parsed.action_items) ? parsed.action_items .map((item) => ({ text: sanitizeSummaryField(normalizeGeneratedText(item?.text || '')), assignee: sanitizeSummaryField(normalizeGeneratedText(item?.assignee || '')) })) .filter((item) => Boolean(item.text)) : []; const blockers = Array.isArray(parsed.blockers) ? parsed.blockers .map((item) => sanitizeSummaryField(normalizeGeneratedText(item))) .filter(Boolean) : []; const decisions = Array.isArray(parsed.decisions) ? parsed.decisions .map((item) => sanitizeSummaryField(normalizeGeneratedText(item))) .filter(Boolean) : []; return { summary, action_items: actionItems, blockers, decisions }; } function parseJsonFromText(text) { if (!text) return null; try { return JSON.parse(text); } catch (error) { const match = text.match(/\{[\s\S]*\}/); if (!match) return null; try { return JSON.parse(match[0]); } catch (inner) { return null; } } } function formatStandupText(result, humanDate = '') { const summary = sanitizeSummaryField(result.summary || ''); const actionItems = (result.action_items || []).map((item) => `- ${item.text}${item.assignee ? ` (owner: ${item.assignee})` : ''}`).join('\n'); const blockers = (result.blockers || []).map((item) => `- ${item}`).join('\n'); const decisions = (result.decisions || []).map((item) => `- ${item}`).join('\n'); return [ '## Meeting Summary', humanDate ? `Date: ${humanDate}` : '', '', summary || '- None', '', '### Action Items', actionItems || '- None', '', '### Blockers', blockers || '- None', '', '### Decisions', decisions || '- None' ].join('\n'); } function buildDefaultNextcloudTemplate() { return JSON.stringify( { path: 'notes/daily/standup-{{date}}.txt', content: 'Daily Standup - {{date_human}}\n\nSummary\n{{summary_full}}\n\nAction Items\n{{action_items}}\n\nBlockers\n{{blockers}}\n\nDecisions\n{{decisions}}' }, null, 2 ); } function buildTemplateArgs(template, context) { try { const parsedTemplate = JSON.parse(template); return applyTemplateToValue(parsedTemplate, context); } catch (error) { throw new Error('Standup args template must be valid JSON.'); } } function applyTemplateToValue(value, context) { if (typeof value === 'string') { return renderTemplateString(value, context); } if (Array.isArray(value)) { return value.map((item) => applyTemplateToValue(item, context)); } if (value && typeof value === 'object') { const next = {}; Object.keys(value).forEach((key) => { next[key] = applyTemplateToValue(value[key], context); }); return next; } return value; } function renderTemplateString(template, context) { return Object.keys(context).reduce((result, key) => { const value = context[key] ?? ''; return result.split(`{{${key}}}`).join(String(value)); }, template); } async function approveAutomation(sendResponse) { if (!state.pendingAutomation) { sendResponse({ success: false, error: 'No pending automation.' }); return; } const { trigger, automation, options } = state.pendingAutomation; state.pendingAutomation = null; const result = await runAutomationByType(automation, options || { testMode: false }); sendResponse({ success: true, trigger, result }); } function rejectAutomation(sendResponse) { if (!state.pendingAutomation) { sendResponse({ success: false, error: 'No pending automation.' }); return; } const trigger = state.pendingAutomation.trigger; state.pendingAutomation = null; sendResponse({ success: true, trigger }); } async function runAutomationByType(automation, options) { if (automation.kind === 'standup') { const result = await runStandupAutomationFor(automation, options || { testMode: false }); return { success: true, kind: 'standup', result }; } const actions = Array.isArray(automation.actions) ? automation.actions : []; if (!actions.length) { return { success: false, error: 'No actions configured.' }; } const templateContext = await buildAutomationTemplateContext(); const results = []; for (const action of actions) { try { let result; if (action.type === 'webhook') { result = await executeWebhookAction(action, templateContext); } else { result = await callMcpToolInternal(action.toolName, action.args || {}); } results.push({ action: action.label, success: true, result }); } catch (error) { results.push({ action: action.label, success: false, error: error.message }); } } return { success: true, kind: 'actions', results }; } function describeAutomationTargets(automation) { if (automation.kind === 'standup') { const targets = []; if (automation.standup?.discordToolName) targets.push({ label: 'Discord', toolName: automation.standup.discordToolName }); if (automation.standup?.nextcloudToolName) targets.push({ label: 'Nextcloud', toolName: automation.standup.nextcloudToolName }); return targets; } return (automation.actions || []).map((action) => ({ label: action.label, toolName: action.type === 'webhook' ? 'webhook' : action.toolName })); } async function getAutomationsWithMigration() { const settings = await getAdvancedSettings(); if (Array.isArray(settings.automations) && settings.automations.length > 0) { return settings.automations; } const automations = []; if (settings.automation) { automations.push({ id: 'legacy-actions', name: 'Automation Actions', kind: 'actions', enabled: Boolean(settings.automation.enabled), triggers: settings.automation.triggers || { sessionStart: false, sessionEnd: true, manual: true }, requireApproval: settings.automation.requireApproval !== false, actions: settings.automation.actions || [] }); } if (settings.standupAutomation) { automations.push({ id: 'legacy-standup', name: 'Daily Standup', kind: 'standup', enabled: Boolean(settings.standupAutomation.enabled), triggers: settings.standupAutomation.triggers || { sessionEnd: true, manual: true }, requireApproval: false, standup: { discordToolName: settings.standupAutomation.discordToolName || '', discordArgsTemplate: settings.standupAutomation.discordArgsTemplate || '', nextcloudToolName: settings.standupAutomation.nextcloudToolName || '', nextcloudArgsTemplate: settings.standupAutomation.nextcloudArgsTemplate || '' } }); } if (automations.length) { const nextSettings = { ...settings, automations }; chrome.storage.sync.set({ advancedSettings: nextSettings }); } return automations; } async function callMcpToolInternal(toolName, args) { const { mcpServerUrl, mcpApiKey } = await getAdvancedSettings(); if (!mcpServerUrl) { throw new Error('MCP server URL is not set.'); } const endpoint = resolveMcpEndpoint(mcpServerUrl); await ensureMcpInitialized(endpoint, mcpApiKey); return mcpRequest(endpoint, mcpApiKey, 'tools/call', { name: toolName, arguments: args || {} }); } async function buildAutomationTemplateContext() { const now = new Date(); const dateIso = now.toISOString().slice(0, 10); const timeIso = now.toISOString().slice(11, 19); const dateTimeIso = `${dateIso}_${timeIso.replace(/:/g, '-')}`; const humanDate = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const latestSummary = await getLatestSummaryText(); const structuredSummary = ensureStructuredReport(latestSummary.text, latestSummary.excerpt || '', humanDate); return { date: dateIso, time: timeIso, datetime: dateTimeIso, date_human: humanDate, summary: structuredSummary, summary_full: structuredSummary, summary_plain: latestSummary.text, summary_source: latestSummary.source, transcript_excerpt: latestSummary.excerpt || '', session_id: latestSummary.sessionId || '' }; } async function getLatestSummaryText() { const store = await getMemoryStore(); const summaries = Array.isArray(store.summaries) ? store.summaries : []; if (summaries.length) { const sorted = [...summaries].sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')); const latest = sorted.find((item) => sanitizeSummaryField(item?.content || '')); if (latest) { return { text: sanitizeSummaryField(latest.content || ''), source: 'memory_summary', sessionId: latest.sessionId || '' }; } } const sessions = Array.isArray(store.sessions) ? store.sessions : []; if (sessions.length) { const preferredId = state.lastSessionId || state.currentSessionId || null; const byRecent = [...sessions].sort((a, b) => { const aDate = a.endedAt || a.startedAt || a.createdAt || ''; const bDate = b.endedAt || b.startedAt || b.createdAt || ''; return bDate.localeCompare(aDate); }); const preferred = preferredId ? sessions.find((session) => session.id === preferredId) : null; const session = preferred || byRecent[0]; if (session?.summaryId) { const linked = summaries.find((item) => item.id === session.summaryId); const linkedText = sanitizeSummaryField(linked?.content || ''); if (linkedText) { return { text: linkedText, source: 'session_summary', sessionId: session.id || '' }; } } const transcriptEntries = Array.isArray(session?.transcript) ? session.transcript : []; const transcriptLines = transcriptEntries .map((entry) => sanitizeSummaryField(normalizeGeneratedText(entry?.text || ''))) .filter(Boolean) .filter((line) => !TRANSCRIPT_NOISE_PATTERNS.some((pattern) => pattern.test(line))); if (transcriptLines.length) { const excerptLines = transcriptLines.slice(-8); const excerpt = excerptLines.join('\n'); const summary = sanitizeSummaryField(excerptLines.join(' ')).slice(0, 1600); return { text: summary || 'Session transcript is available, but could not be summarized.', source: 'session_transcript', excerpt, sessionId: session.id || '' }; } } return { text: 'Session ended. No summary content was captured yet.', source: 'fallback', sessionId: state.lastSessionId || '' }; } function ensureStructuredReport(summaryText, transcriptExcerpt = '', humanDate = '') { const text = sanitizeSummaryField(normalizeGeneratedText(summaryText || '')); if (text.startsWith('## Standup Summary') || text.startsWith('## Session Summary') || text.startsWith('## Meeting Summary')) { return normalizeMarkdownReport(text, humanDate); } const excerptLines = sanitizeSummaryField(normalizeGeneratedText(transcriptExcerpt || '')) .split('\n') .map((line) => line.trim()) .filter(Boolean) .slice(-6); const keyNotes = excerptLines.length ? excerptLines.map((line) => `- ${line}`).join('\n') : '- None captured.'; const summaryBody = text || 'No summary content was captured yet.'; return [ '## Meeting Summary', humanDate ? `Date: ${humanDate}` : '', '', summaryBody, '', '### Action Items', '- None reported.', '', '### Blockers', '- None reported.', '', '### Decisions', '- None reported.', '', '### Key Notes', keyNotes ].join('\n'); } function normalizeMarkdownReport(reportText, humanDate = '') { if (!reportText) return ''; let text = String(reportText).replace(/\r\n/g, '\n'); // Recover common flattened markdown patterns. text = text .replace(/(##\s+[^\n#]+?)\s+(?=###\s+)/g, '$1\n\n') .replace(/(##\s+[^\n#]+?)\s+(?=-\s+)/g, '$1\n\n') .replace(/\s+(###\s+)/g, '\n\n$1') .replace(/\s+-\s+/g, '\n- '); return canonicalizeReportSections(text, humanDate); } function canonicalizeReportSections(text, humanDate = '') { const lines = String(text || '') .split('\n') .map((line) => line.trim()) .filter((line) => Boolean(line)); // Repair a common split artifact: "Standup Summar," + "y" for (let i = 0; i < lines.length - 1; i += 1) { if (/^standup summar,?$/i.test(lines[i]) && /^y$/i.test(lines[i + 1])) { lines[i] = 'Meeting Summary'; lines.splice(i + 1, 1); break; } } const output = []; let currentSection = 'summary'; const pushSection = (title, level = 3) => { if (output.length) output.push(''); output.push(`${'#'.repeat(level)} ${title}`); output.push(''); }; const sectionForLine = (line) => { const normalized = line.replace(/[,:;\-]+$/g, '').toLowerCase(); if (normalized === 'standup summary' || normalized === 'session summary' || normalized === 'meeting summary') { return { key: 'summary', title: 'Meeting Summary', level: 2 }; } if (normalized === 'action items') return { key: 'actions', title: 'Action Items', level: 3 }; if (normalized === 'blockers') return { key: 'blockers', title: 'Blockers', level: 3 }; if (normalized === 'decisions') return { key: 'decisions', title: 'Decisions', level: 3 }; if (normalized === 'key notes') return { key: 'notes', title: 'Key Notes', level: 3 }; return null; }; // Ensure report always starts with top heading. const firstSection = sectionForLine(lines[0] || ''); if (!firstSection || firstSection.key !== 'summary') { pushSection('Meeting Summary', 2); } if (humanDate) { output.push(`Date: ${humanDate}`); output.push(''); } for (const rawLine of lines) { const line = rawLine.replace(/\s+/g, ' ').replace(/[,:;]+$/g, '').trim(); if (!line) continue; const nextSection = sectionForLine(line); if (nextSection) { currentSection = nextSection.key; pushSection(nextSection.title, nextSection.level); continue; } if (currentSection === 'summary') { output.push(line); } else if (/^- /.test(line)) { output.push(line); } else { output.push(`- ${line}`); } } return output .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } async function executeWebhookAction(action, context) { const settings = await getAdvancedSettings(); const url = action.webhookUrl || settings.webhookUrl; if (!url) { throw new Error('Webhook action missing URL and no global webhook URL is configured.'); } const method = action.method || 'POST'; const headers = { ...(action.headers || {}) }; const hasExplicitContentType = Boolean(headers['Content-Type'] || headers['content-type']); const retryValue = Number(action.retryCount); const retryCount = Number.isFinite(retryValue) ? Math.max(0, Math.min(5, Math.floor(retryValue))) : 0; const templateSource = action.bodyTemplate || settings.webhookPayload || '{"message":"{{summary}}","date":"{{date}}"}'; let bodyToSend = templateSource; try { // Parse template JSON first, then apply placeholders to values to avoid broken escaping. const parsedTemplate = JSON.parse(templateSource); const templatedObject = applyTemplateToValue(parsedTemplate, context || {}); bodyToSend = JSON.stringify(templatedObject); if (!hasExplicitContentType) { headers['Content-Type'] = 'application/json'; } } catch (error) { // Non-JSON template: render as plain text payload. bodyToSend = renderTemplateString(templateSource, context || {}); if (!hasExplicitContentType) { headers['Content-Type'] = 'text/plain'; } } let lastError = null; for (let attempt = 0; attempt <= retryCount; attempt += 1) { try { const response = await fetch(url, { method, headers, body: bodyToSend }); const text = await response.text().catch(() => ''); if (!response.ok) { throw new Error(`Webhook responded with ${response.status}${text ? `: ${text}` : ''}`); } return { status: response.status, response: text }; } catch (error) { lastError = error; } } throw lastError || new Error('Webhook action failed.'); } function resolveMcpEndpoint(rawUrl) { if (!rawUrl) return ''; const trimmed = rawUrl.replace(/\/$/, ''); if (trimmed.endsWith('/mcp')) return trimmed; return `${trimmed}/mcp`; } function getAIConfigFromStorage() { return new Promise((resolve) => { chrome.storage.sync.get(['aiProvider', 'selectedModel'], (result) => { if (chrome.runtime.lastError) { resolve(null); return; } const provider = result.aiProvider; const model = result.selectedModel; if (!provider || !model) { resolve(null); return; } resolve({ provider, model }); }); }); } function getApiKey(provider) { return new Promise((resolve) => { chrome.storage.sync.get('apiKeys', (result) => { const apiKeys = result.apiKeys || {}; resolve(apiKeys[provider]); }); }); } function getSttConfigFromStorage() { return new Promise((resolve) => { chrome.storage.sync.get( ['sttProvider', 'sttModel', 'sttApiKeys', 'apiKeys', 'sttEndpoint', 'sttLanguageMode', 'sttForcedLanguage', 'sttTask', 'sttVadFilter', 'sttBeamSize'], (result) => { const provider = result.sttProvider || 'openai'; const model = result.sttModel || 'whisper-1'; const sttApiKeys = result.sttApiKeys || {}; const apiKeys = result.apiKeys || {}; const apiKey = sttApiKeys[provider] || (provider === 'openai' ? apiKeys.openai : ''); const endpoint = result.sttEndpoint || 'http://localhost:8790/transcribe'; const languageMode = result.sttLanguageMode || 'auto'; const forcedLanguage = String(result.sttForcedLanguage || '').trim().toLowerCase(); const task = result.sttTask || 'transcribe'; const vadFilter = result.sttVadFilter !== false; const beamSize = Math.min(10, Math.max(1, Number(result.sttBeamSize) || 5)); const language = languageMode === 'forced' && forcedLanguage ? forcedLanguage : (languageMode === 'auto' && state.sttSessionLanguage ? state.sttSessionLanguage : ''); resolve({ provider, model, apiKey, endpoint, languageMode, forcedLanguage, language, task, vadFilter, beamSize }); } ); }); } function normalizeLocalSttEndpoint(rawEndpoint) { if (!rawEndpoint) return 'http://localhost:8790/transcribe'; const trimmed = rawEndpoint.replace(/\/$/, ''); if (trimmed.endsWith('/transcribe')) return trimmed; return `${trimmed}/transcribe`; } async function transcribeWithLocalBridge(sttConfig, audioBase64, mimeType, captureMode) { const endpoint = normalizeLocalSttEndpoint(sttConfig.endpoint); const headers = {}; if (sttConfig.apiKey) { headers.Authorization = `Bearer ${sttConfig.apiKey}`; headers['x-api-key'] = sttConfig.apiKey; } const blob = decodeBase64ToBlob(audioBase64, mimeType || 'audio/webm'); const extension = (mimeType || 'audio/webm').includes('mp4') ? 'mp4' : 'webm'; const formData = new FormData(); formData.append('file', blob, `chunk.${extension}`); formData.append('task', sttConfig.task || 'transcribe'); formData.append('vad_filter', String(Boolean(sttConfig.vadFilter))); formData.append('beam_size', String(sttConfig.beamSize || 5)); if (sttConfig.language) { formData.append('language', sttConfig.language); } if (sttConfig.model) { formData.append('model', sttConfig.model); } const response = await fetch(endpoint, { method: 'POST', headers, body: formData }); if (!response.ok) { const errorText = await response.text(); return { success: false, error: `Local STT bridge failed (${response.status}): ${errorText}` }; } const data = await response.json(); const transcript = normalizeGeneratedText((data.text || data.transcript || '').trim()); return { success: true, transcript, language: data.language || '' }; } async function testSttConnection() { const sttConfig = await getSttConfigFromStorage(); if (sttConfig.provider === 'browser') { return { success: true, message: 'Browser STT selected. No remote connection required.' }; } if (sttConfig.provider === 'local') { const endpoint = normalizeLocalSttEndpoint(sttConfig.endpoint); const healthEndpoint = endpoint.replace(/\/transcribe$/, '/health'); const response = await fetch(healthEndpoint, { method: 'GET' }); if (!response.ok) { const text = await response.text(); return { success: false, error: `Local STT health check failed (${response.status}): ${text}` }; } return { success: true, message: `Local STT bridge reachable at ${healthEndpoint}` }; } if (sttConfig.provider === 'openai') { if (!sttConfig.apiKey) { return { success: false, error: 'Missing OpenAI STT API key.' }; } const response = await fetch('https://api.openai.com/v1/models', { method: 'GET', headers: { Authorization: `Bearer ${sttConfig.apiKey}` } }); if (!response.ok) { const text = await response.text(); return { success: false, error: `OpenAI STT check failed (${response.status}): ${text}` }; } return { success: true, message: 'OpenAI STT connection successful.' }; } return { success: false, error: `Unsupported STT provider: ${sttConfig.provider}` }; } function decodeBase64ToBlob(base64Audio, mimeType = 'audio/webm') { const binaryString = atob(base64Audio || ''); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i += 1) { bytes[i] = binaryString.charCodeAt(i); } return new Blob([bytes], { type: mimeType }); } async function transcribeAudioChunk(audioBase64, mimeType, captureMode) { if (!audioBase64) { return { success: false, error: 'No audio chunk provided.' }; } const sttConfig = await getSttConfigFromStorage(); if (sttConfig.provider === 'browser') { return { success: false, error: 'Browser STT is selected. Switch STT provider to OpenAI for true tab/mixed transcription.' }; } if (sttConfig.provider === 'local') { const localResult = await transcribeWithLocalBridge(sttConfig, audioBase64, mimeType, captureMode); if (!localResult.success) return localResult; const localTranscript = localResult.transcript; const localLanguage = String(localResult.language || '').trim().toLowerCase(); if (!localTranscript) { return { success: true, transcript: '' }; } if (sttConfig.languageMode === 'auto' && !state.sttSessionLanguage && localLanguage) { state.sttSessionLanguage = localLanguage; } chrome.runtime.sendMessage({ action: 'updateTranscript', transcript: localTranscript }); appendTranscriptToCurrentSession(localTranscript); if (isQuestion(localTranscript)) { getAIResponse(localTranscript); } return { success: true, transcript: localTranscript }; } if (sttConfig.provider !== 'openai') { return { success: false, error: `Unsupported STT provider: ${sttConfig.provider}` }; } if (!sttConfig.apiKey) { return { success: false, error: 'STT API key missing. Save OpenAI STT key in Assistant Setup.' }; } const blob = decodeBase64ToBlob(audioBase64, mimeType || 'audio/webm'); const formData = new FormData(); formData.append('file', blob, `chunk.${(mimeType || 'audio/webm').includes('mp4') ? 'mp4' : 'webm'}`); formData.append('model', sttConfig.model || 'whisper-1'); formData.append('response_format', 'verbose_json'); if (sttConfig.language) { formData.append('language', sttConfig.language); } const sttPath = sttConfig.task === 'translate' ? 'https://api.openai.com/v1/audio/translations' : 'https://api.openai.com/v1/audio/transcriptions'; const response = await fetch(sttPath, { method: 'POST', headers: { Authorization: `Bearer ${sttConfig.apiKey}` }, body: formData }); if (!response.ok) { const errorText = await response.text(); return { success: false, error: `Transcription failed (${response.status}): ${errorText}` }; } const data = await response.json(); const transcript = normalizeGeneratedText((data.text || data.transcript || '').trim()); const detectedLanguage = String(data.language || '').trim().toLowerCase(); if (!transcript) { return { success: true, transcript: '' }; } if (sttConfig.languageMode === 'auto' && !state.sttSessionLanguage && detectedLanguage) { state.sttSessionLanguage = detectedLanguage; } chrome.runtime.sendMessage({ action: 'updateTranscript', transcript }); appendTranscriptToCurrentSession(transcript); if (isQuestion(transcript)) { getAIResponse(transcript); } else if (captureMode === 'tab' || captureMode === 'mixed') { chrome.runtime.sendMessage({ action: 'updateAIResponse', response: 'Listening... (ask a question to get a response)' }); } return { success: true, transcript }; } function initializeContextProfiles() { chrome.storage.sync.get([CONTEXT_PROFILES_STORAGE_KEY, ACTIVE_CONTEXT_PROFILE_STORAGE_KEY], (result) => { const existingProfiles = Array.isArray(result[CONTEXT_PROFILES_STORAGE_KEY]) ? result[CONTEXT_PROFILES_STORAGE_KEY] : []; const existingActive = result[ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]; if (!existingProfiles.length) { state.activeContextProfileId = existingActive || DEFAULT_CONTEXT_PROFILE_ID; chrome.storage.sync.set({ [CONTEXT_PROFILES_STORAGE_KEY]: DEFAULT_CONTEXT_PROFILES, [ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]: existingActive || DEFAULT_CONTEXT_PROFILE_ID }); return; } if (!existingActive) { chrome.storage.sync.set({ [ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]: existingProfiles[0].id || DEFAULT_CONTEXT_PROFILE_ID }); state.activeContextProfileId = existingProfiles[0].id || DEFAULT_CONTEXT_PROFILE_ID; return; } state.activeContextProfileId = existingActive; }); } function getActiveContextProfile() { return new Promise((resolve) => { chrome.storage.sync.get([CONTEXT_PROFILES_STORAGE_KEY, ACTIVE_CONTEXT_PROFILE_STORAGE_KEY], (result) => { const profiles = Array.isArray(result[CONTEXT_PROFILES_STORAGE_KEY]) && result[CONTEXT_PROFILES_STORAGE_KEY].length ? result[CONTEXT_PROFILES_STORAGE_KEY] : DEFAULT_CONTEXT_PROFILES; const requestedId = state.activeContextProfileId || result[ACTIVE_CONTEXT_PROFILE_STORAGE_KEY] || DEFAULT_CONTEXT_PROFILE_ID; const activeProfile = profiles.find((profile) => profile.id === requestedId) || profiles[0] || DEFAULT_CONTEXT_PROFILES[0]; resolve(activeProfile); }); }); } function getModePolicy(profile) { const mode = profile && profile.mode ? profile.mode : 'interview'; const basePolicy = DEFAULT_MODE_POLICIES[mode] || DEFAULT_MODE_POLICIES.custom; const customPrompt = profile && typeof profile.systemPrompt === 'string' ? profile.systemPrompt.trim() : ''; return { ...basePolicy, systemPrompt: customPrompt || basePolicy.systemPrompt || DEFAULT_LISTENING_PROMPT }; } function getStoredContexts(profileId) { return new Promise((resolve) => { chrome.storage.local.get([CONTEXTS_BY_PROFILE_STORAGE_KEY, 'contexts'], (result) => { const byProfile = result[CONTEXTS_BY_PROFILE_STORAGE_KEY] || {}; const profileContexts = Array.isArray(byProfile[profileId]) ? byProfile[profileId] : null; if (profileContexts) { resolve(profileContexts); return; } resolve(result.contexts || []); }); }); } function startRemoteServer(sessionId, port, sendResponse) { try { state.remoteServerPort = port; console.log(`Starting remote server on port ${port} with session ${sessionId}`); sendResponse({ success: true, message: 'Remote server started (demo mode)', url: `http://localhost:${port}?session=${sessionId}` }); } catch (error) { console.error('Error starting remote server:', error); sendResponse({ success: false, error: error.message }); } } function stopRemoteServer(sendResponse) { state.remoteServer = null; state.remoteServerPort = null; state.activeConnections.clear(); console.log('Remote server stopped'); sendResponse({ success: true }); } function broadcastToRemoteDevices(type, data) { console.log('Broadcasting to remote devices:', type, data); if (state.activeConnections.size > 0) { console.log(`Broadcasting ${type} to ${state.activeConnections.size} connected devices`); } } function isValidCaptureTab(tab) { if (!tab || !tab.url) return false; return !tab.url.startsWith('chrome://') && !tab.url.startsWith('chrome-extension://'); } function buildTabCaptureErrorMessage(errorMsg) { let userMessage = `Error: ${errorMsg}.`; if (errorMsg.includes('Extension has not been invoked')) { userMessage += ' Click the extension icon on the tab you want to capture, then press Start Listening.'; } else { userMessage += ' Make sure you\'ve granted microphone permissions.'; } return userMessage; } function initializeActiveState() { // Extension is always active now; Start/Stop listening is the only user control. chrome.storage.sync.set({ extensionActive: true }, () => { updateActionBadge(); }); } function initializeMemoryStore() { chrome.storage.local.get([MEMORY_STORAGE_KEY], (result) => { if (chrome.runtime.lastError) { return; } if (!result[MEMORY_STORAGE_KEY]) { chrome.storage.local.set({ [MEMORY_STORAGE_KEY]: createDefaultMemoryStore() }); } }); } function getMemoryStore() { return new Promise((resolve) => { chrome.storage.local.get([MEMORY_STORAGE_KEY], (result) => { if (chrome.runtime.lastError) { resolve(createDefaultMemoryStore()); return; } const store = result[MEMORY_STORAGE_KEY] || createDefaultMemoryStore(); if (store.version !== MEMORY_SCHEMA_VERSION) { const migrated = { ...createDefaultMemoryStore(), ...store, version: MEMORY_SCHEMA_VERSION }; chrome.storage.local.set({ [MEMORY_STORAGE_KEY]: migrated }, () => resolve(migrated)); return; } resolve(store); }); }); } function setMemoryStore(store) { return new Promise((resolve) => { chrome.storage.local.set({ [MEMORY_STORAGE_KEY]: store }, () => resolve(store)); }); } function updateMemoryProfile(profileUpdates) { return getMemoryStore().then((store) => { const updatedProfile = { ...store.profile, ...profileUpdates, updatedAt: new Date().toISOString() }; const nextStore = { ...store, profile: updatedProfile }; return setMemoryStore(nextStore); }); } function addMemorySession(sessionInput) { return getMemoryStore().then((store) => { const session = { id: sessionInput.id || createMemoryId('session'), title: sessionInput.title || 'Session', createdAt: sessionInput.createdAt || new Date().toISOString(), status: sessionInput.status || SESSION_STATUS.ACTIVE, startedAt: sessionInput.startedAt || null, pausedAt: sessionInput.pausedAt || null, endedAt: sessionInput.endedAt || null, notes: sessionInput.notes || '', storeConsent: Boolean(sessionInput.storeConsent), transcript: Array.isArray(sessionInput.transcript) ? sessionInput.transcript : [], summaryId: sessionInput.summaryId || null }; const nextStore = { ...store, sessions: [...store.sessions, session] }; return setMemoryStore(nextStore).then(() => session); }); } function addMemorySummary(summaryInput) { return getMemoryStore().then((store) => { const summary = { id: summaryInput.id || createMemoryId('summary'), sessionId: summaryInput.sessionId || null, createdAt: summaryInput.createdAt || new Date().toISOString(), content: summaryInput.content || '', highlights: Array.isArray(summaryInput.highlights) ? summaryInput.highlights : [] }; const nextStore = { ...store, summaries: [...store.summaries, summary] }; return setMemoryStore(nextStore).then(() => summary); }); } function addMemoryActionItems(itemsInput, sessionId) { return getMemoryStore().then((store) => { const items = (Array.isArray(itemsInput) ? itemsInput : []).map((item) => ({ id: item.id || createMemoryId('action'), sessionId: item.sessionId || sessionId || null, createdAt: item.createdAt || new Date().toISOString(), text: item.text || '', owner: item.owner || '', dueAt: item.dueAt || null, done: Boolean(item.done) })); const nextStore = { ...store, actionItems: [...store.actionItems, ...items] }; return setMemoryStore(nextStore).then(() => items); }); } function clearMemoryStore() { const store = createDefaultMemoryStore(); return setMemoryStore(store); } function createMemoryId(prefix) { if (crypto && typeof crypto.randomUUID === 'function') { return `${prefix}_${crypto.randomUUID()}`; } return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; } function buildMemoryContext(question, store, speedMode) { if (!store) return ''; const docs = buildMemoryDocuments(store); if (!docs.length) return ''; const ranked = rankDocuments(question, docs); const maxItems = speedMode ? Math.max(1, Math.floor(RAG_MAX_ITEMS / 2)) : RAG_MAX_ITEMS; const selected = ranked.filter((item) => item.score >= RAG_MIN_SCORE).slice(0, maxItems); if (!selected.length) return ''; return selected .map((item) => `Memory (${item.type}${item.title ? `: ${item.title}` : ''}):\n${item.content}`) .join('\n\n---\n\n'); } function buildMemoryDocuments(store) { const docs = []; const profile = store.profile || {}; const profileContent = [profile.name, profile.role, profile.notes].filter(Boolean).join('\n'); if (profileContent.trim()) { docs.push({ id: 'profile', type: 'profile', title: profile.name || profile.role || 'Profile', content: profileContent }); } const summaries = Array.isArray(store.summaries) ? store.summaries : []; summaries.forEach((summary) => { if (!summary || !summary.content) return; docs.push({ id: summary.id, type: 'summary', title: summary.sessionId ? `Session ${summary.sessionId}` : 'Session summary', content: summary.content }); }); return docs; } function rankDocuments(query, docs) { const queryTokens = tokenize(query); if (!queryTokens.length) return []; const docTokens = docs.map((doc) => tokenize(doc.content)); const idf = buildIdf(docTokens); const queryVector = buildTfIdfVector(queryTokens, idf); return docs .map((doc, index) => { const vector = buildTfIdfVector(docTokens[index], idf); return { ...doc, score: cosineSimilarity(queryVector, vector) }; }) .sort((a, b) => b.score - a.score); } function tokenize(text) { return String(text || '') .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) .filter((token) => token.length > 2); } function buildIdf(docTokens) { const docCount = docTokens.length; const docFreq = {}; docTokens.forEach((tokens) => { const seen = new Set(tokens); seen.forEach((token) => { docFreq[token] = (docFreq[token] || 0) + 1; }); }); const idf = {}; Object.keys(docFreq).forEach((token) => { idf[token] = Math.log((docCount + 1) / (docFreq[token] + 1)) + 1; }); return idf; } function buildTfIdfVector(tokens, idf) { const tf = {}; tokens.forEach((token) => { tf[token] = (tf[token] || 0) + 1; }); const vector = {}; Object.keys(tf).forEach((token) => { vector[token] = tf[token] * (idf[token] || 0); }); return vector; } function cosineSimilarity(a, b) { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (!aKeys.length || !bKeys.length) return 0; let dot = 0; let aMag = 0; let bMag = 0; aKeys.forEach((key) => { const value = a[key]; aMag += value * value; if (b[key]) { dot += value * b[key]; } }); bKeys.forEach((key) => { const value = b[key]; bMag += value * value; }); if (aMag === 0 || bMag === 0) return 0; return dot / (Math.sqrt(aMag) * Math.sqrt(bMag)); } function updateMemorySession(sessionId, updates) { return getMemoryStore().then((store) => { const index = store.sessions.findIndex((session) => session.id === sessionId); if (index === -1) { return null; } const updated = { ...store.sessions[index], ...updates }; const nextSessions = [...store.sessions]; nextSessions[index] = updated; const nextStore = { ...store, sessions: nextSessions }; return setMemoryStore(nextStore).then(() => updated); }); } function ensureActiveSession() { if (state.currentSessionStatus === SESSION_STATUS.PAUSED && state.currentSessionId) { state.currentSessionStatus = SESSION_STATUS.ACTIVE; updateMemorySession(state.currentSessionId, { status: SESSION_STATUS.ACTIVE, pausedAt: null }); return; } if (state.currentSessionStatus === SESSION_STATUS.ACTIVE && state.currentSessionId) { return; } addMemorySession({ status: SESSION_STATUS.ACTIVE, startedAt: new Date().toISOString(), storeConsent: Boolean(state.pendingSessionConsent) }).then((session) => { state.currentSessionId = session.id; state.currentSessionStatus = SESSION_STATUS.ACTIVE; state.currentSessionConsent = Boolean(state.pendingSessionConsent); state.pendingSessionConsent = null; }); } function pauseCurrentSession() { if (!state.currentSessionId || state.currentSessionStatus !== SESSION_STATUS.ACTIVE) { return; } state.currentSessionStatus = SESSION_STATUS.PAUSED; updateMemorySession(state.currentSessionId, { status: SESSION_STATUS.PAUSED, pausedAt: new Date().toISOString() }); } function endCurrentSession() { if (!state.currentSessionId || state.currentSessionStatus === SESSION_STATUS.IDLE) { return; } const sessionId = state.currentSessionId; state.currentSessionId = null; state.currentSessionStatus = SESSION_STATUS.ENDED; state.lastSessionId = sessionId; updateMemorySession(sessionId, { status: SESSION_STATUS.ENDED, endedAt: new Date().toISOString() }); } function appendTranscriptToCurrentSession(text) { if (!text || !state.currentSessionId) { return; } if (!state.currentSessionConsent) { return; } const entry = { text: String(text), createdAt: new Date().toISOString() }; getMemoryStore().then((store) => { const index = store.sessions.findIndex((session) => session.id === state.currentSessionId); if (index === -1) return; const session = store.sessions[index]; const transcript = Array.isArray(session.transcript) ? session.transcript : []; const updated = { ...session, transcript: [...transcript, entry] }; const nextSessions = [...store.sessions]; nextSessions[index] = updated; setMemoryStore({ ...store, sessions: nextSessions }); }); } function setCurrentSessionConsent(consent) { state.currentSessionConsent = consent; state.pendingSessionConsent = consent; if (!state.currentSessionId) return; updateMemorySession(state.currentSessionId, { storeConsent: consent }); } function forgetCurrentSession() { const sessionId = state.currentSessionId || state.lastSessionId; if (!sessionId) return; getMemoryStore().then((store) => { const nextStore = { ...store, sessions: store.sessions.filter((session) => session.id !== sessionId), summaries: store.summaries.filter((summary) => summary.sessionId !== sessionId), actionItems: store.actionItems.filter((item) => item.sessionId !== sessionId) }; setMemoryStore(nextStore); }); state.currentSessionId = null; state.currentSessionStatus = SESSION_STATUS.IDLE; state.currentSessionConsent = false; state.pendingSessionConsent = null; if (state.lastSessionId === sessionId) { state.lastSessionId = null; } } function saveCurrentSessionSummary(content, saveToMemory, sessionIdOverride) { const sessionId = sessionIdOverride || state.currentSessionId || state.lastSessionId; if (!sessionId) { return Promise.resolve({ success: false, error: 'No active session to save.' }); } if (!content.trim()) { return Promise.resolve({ success: false, error: 'Summary is empty.' }); } if (!saveToMemory) { return Promise.resolve({ success: true, saved: false }); } return addMemorySummary({ sessionId, content }).then((summary) => { updateMemorySession(sessionId, { summaryId: summary.id }); return { success: true, saved: true, summaryId: summary.id }; }); } function updateActionBadge() { if (!chrome.action || !chrome.action.setBadgeText) return; chrome.action.setBadgeText({ text: '' }); }