2341 lines
78 KiB
JavaScript
2341 lines
78 KiB
JavaScript
'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: '' });
|
|
}
|