Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/anshu-ireland_natively.json
T

1 line
4.5 MiB
Plaintext

{"repo": "anshu-ireland/natively", "files": {"electron/ProcessingHelper.ts": {"681d3bde5b2fd4043a4735ac5e519efd6fe7e106": {"t": 1785745577.413847, "n": 10683, "h": "b7abed8a0382fb71", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n let deepseekApiKey = process.env.DEEPSEEK_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey, deepseekApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n const deepseekKey = credManager.getDeepseekApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n if (deepseekKey) {\n console.log(\"[ProcessingHelper] Loading stored DeepSeek API Key from CredentialsManager\");\n this.llmHelper.setDeepseekApiKey(deepseekKey);\n }\n\n const nativelyKey = credManager.getNativelyApiKey();\n if (nativelyKey) {\n console.log(\"[ProcessingHelper] Loading stored Natively API Key from CredentialsManager\");\n this.llmHelper.setNativelyKey(nativelyKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n providerDataScopes: (() => { try { const { SettingsManager } = require('./services/SettingsManager'); return SettingsManager.getInstance().get('providerDataScopes'); } catch { return undefined; } })()\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n\n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n\n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n // Generate the structured 4-phase rolling interview script\n const rollingScript = await this.llmHelper.generateRollingScript(allPaths);\n\n const problemInfo = {\n problem_statement: rollingScript.problem_identifier_script,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"structured\" },\n complexity: { time: rollingScript.time_complexity, space: rollingScript.space_complexity },\n test_cases: [] as any[],\n validation_type: \"structured\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n\n // Send the full structured solution so Solutions.tsx renders the 4 phases\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.SOLUTION_SUCCESS, {\n solution: {\n problem_identifier_script: rollingScript.problem_identifier_script,\n brainstorm_script: rollingScript.brainstorm_script,\n code: rollingScript.code,\n dry_run_script: rollingScript.dry_run_script,\n time_complexity: rollingScript.time_complexity,\n space_complexity: rollingScript.space_complexity,\n }\n });\n } catch (error: any) {\n console.error(\"[ProcessingHelper] Rolling script generation failed:\", error);\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "18b139b123c12f742136e5d1b7def980530d9d08": {"t": 1785754401.067134, "n": 10683, "h": "b7abed8a0382fb71", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n let deepseekApiKey = process.env.DEEPSEEK_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey, deepseekApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n const deepseekKey = credManager.getDeepseekApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n if (deepseekKey) {\n console.log(\"[ProcessingHelper] Loading stored DeepSeek API Key from CredentialsManager\");\n this.llmHelper.setDeepseekApiKey(deepseekKey);\n }\n\n const nativelyKey = credManager.getNativelyApiKey();\n if (nativelyKey) {\n console.log(\"[ProcessingHelper] Loading stored Natively API Key from CredentialsManager\");\n this.llmHelper.setNativelyKey(nativelyKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n providerDataScopes: (() => { try { const { SettingsManager } = require('./services/SettingsManager'); return SettingsManager.getInstance().get('providerDataScopes'); } catch { return undefined; } })()\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n\n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n\n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n // Generate the structured 4-phase rolling interview script\n const rollingScript = await this.llmHelper.generateRollingScript(allPaths);\n\n const problemInfo = {\n problem_statement: rollingScript.problem_identifier_script,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"structured\" },\n complexity: { time: rollingScript.time_complexity, space: rollingScript.space_complexity },\n test_cases: [] as any[],\n validation_type: \"structured\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n\n // Send the full structured solution so Solutions.tsx renders the 4 phases\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.SOLUTION_SUCCESS, {\n solution: {\n problem_identifier_script: rollingScript.problem_identifier_script,\n brainstorm_script: rollingScript.brainstorm_script,\n code: rollingScript.code,\n dry_run_script: rollingScript.dry_run_script,\n time_complexity: rollingScript.time_complexity,\n space_complexity: rollingScript.space_complexity,\n }\n });\n } catch (error: any) {\n console.error(\"[ProcessingHelper] Rolling script generation failed:\", error);\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "18c9f0d048d441cc3e096d942adc712d20237d38": {"t": 1785754402.4058332, "n": 10374, "h": "4b336e556cd82b0e", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n const nativelyKey = credManager.getNativelyApiKey();\n if (nativelyKey) {\n console.log(\"[ProcessingHelper] Loading stored Natively API Key from CredentialsManager\");\n this.llmHelper.setNativelyKey(nativelyKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n providerDataScopes: (() => { try { const { SettingsManager } = require('./services/SettingsManager'); return SettingsManager.getInstance().get('providerDataScopes'); } catch { return undefined; } })()\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n\n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n\n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n // Generate the structured 4-phase rolling interview script\n const rollingScript = await this.llmHelper.generateRollingScript(allPaths);\n\n const problemInfo = {\n problem_statement: rollingScript.problem_identifier_script,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"structured\" },\n complexity: { time: rollingScript.time_complexity, space: rollingScript.space_complexity },\n test_cases: [] as any[],\n validation_type: \"structured\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n\n // Send the full structured solution so Solutions.tsx renders the 4 phases\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.SOLUTION_SUCCESS, {\n solution: {\n problem_identifier_script: rollingScript.problem_identifier_script,\n brainstorm_script: rollingScript.brainstorm_script,\n code: rollingScript.code,\n dry_run_script: rollingScript.dry_run_script,\n time_complexity: rollingScript.time_complexity,\n space_complexity: rollingScript.space_complexity,\n }\n });\n } catch (error: any) {\n console.error(\"[ProcessingHelper] Rolling script generation failed:\", error);\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "c41e3298bccca61cbe7ef80c649e3fac327d790c": {"t": 1785754404.2006605, "n": 10382, "h": "81ddcb044324c63c", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n const nativelyKey = credManager.getNativelyApiKey();\n if (nativelyKey) {\n console.log(\"[ProcessingHelper] Loading stored Natively API Key from CredentialsManager\");\n this.llmHelper.setNativelyKey(nativelyKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n providerDataScopes: (() => { try { const { SettingsManager } = require('./services/SettingsManager'); return SettingsManager.getInstance().get('providerDataScopes'); } catch { return undefined; } })()\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n // Generate the structured 4-phase rolling interview script\n const rollingScript = await this.llmHelper.generateRollingScript(allPaths);\n\n const problemInfo = {\n problem_statement: rollingScript.problem_identifier_script,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"structured\" },\n complexity: { time: rollingScript.time_complexity, space: rollingScript.space_complexity },\n test_cases: [] as any[],\n validation_type: \"structured\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n\n // Send the full structured solution so Solutions.tsx renders the 4 phases\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.SOLUTION_SUCCESS, {\n solution: {\n problem_identifier_script: rollingScript.problem_identifier_script,\n brainstorm_script: rollingScript.brainstorm_script,\n code: rollingScript.code,\n dry_run_script: rollingScript.dry_run_script,\n time_complexity: rollingScript.time_complexity,\n space_complexity: rollingScript.space_complexity,\n }\n });\n } catch (error: any) {\n console.error(\"[ProcessingHelper] Rolling script generation failed:\", error);\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "3c67c6376c19cb8cd605b55052e8e1c28641b2bc": {"t": 1785754407.0318344, "n": 10171, "h": "ebb2343264e5fa57", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n const nativelyKey = credManager.getNativelyApiKey();\n if (nativelyKey) {\n console.log(\"[ProcessingHelper] Loading stored Natively API Key from CredentialsManager\");\n this.llmHelper.setNativelyKey(nativelyKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n // Generate the structured 4-phase rolling interview script\n const rollingScript = await this.llmHelper.generateRollingScript(allPaths);\n\n const problemInfo = {\n problem_statement: rollingScript.problem_identifier_script,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"structured\" },\n complexity: { time: rollingScript.time_complexity, space: rollingScript.space_complexity },\n test_cases: [] as any[],\n validation_type: \"structured\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n\n // Send the full structured solution so Solutions.tsx renders the 4 phases\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.SOLUTION_SUCCESS, {\n solution: {\n problem_identifier_script: rollingScript.problem_identifier_script,\n brainstorm_script: rollingScript.brainstorm_script,\n code: rollingScript.code,\n dry_run_script: rollingScript.dry_run_script,\n time_complexity: rollingScript.time_complexity,\n space_complexity: rollingScript.space_complexity,\n }\n });\n } catch (error: any) {\n console.error(\"[ProcessingHelper] Rolling script generation failed:\", error);\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "3586583c3e3ce80f8c79f3ac805bff882fc349d8": {"t": 1785754409.8171391, "n": 9937, "h": "36d75b4988bc674b", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n // Generate the structured 4-phase rolling interview script\n const rollingScript = await this.llmHelper.generateRollingScript(allPaths);\n\n const problemInfo = {\n problem_statement: rollingScript.problem_identifier_script,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"structured\" },\n complexity: { time: rollingScript.time_complexity, space: rollingScript.space_complexity },\n test_cases: [] as any[],\n validation_type: \"structured\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n\n // Send the full structured solution so Solutions.tsx renders the 4 phases\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.SOLUTION_SUCCESS, {\n solution: {\n problem_identifier_script: rollingScript.problem_identifier_script,\n brainstorm_script: rollingScript.brainstorm_script,\n code: rollingScript.code,\n dry_run_script: rollingScript.dry_run_script,\n time_complexity: rollingScript.time_complexity,\n space_complexity: rollingScript.space_complexity,\n }\n });\n } catch (error: any) {\n console.error(\"[ProcessingHelper] Rolling script generation failed:\", error);\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "7120fa071ed87a6290bb3faad12d2bab5b6966ad": {"t": 1785754411.1458185, "n": 9947, "h": "cdbfa31956e6d8c3", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFiles(allPaths);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"coding\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"coding\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n // Store in session so Code Hint has the question even without a screenshot of the problem\n try {\n const intelligenceManager = this.appState.getIntelligenceManager();\n if (problemInfo.problem_statement?.trim()) {\n intelligenceManager.setCodingQuestion(problemInfo.problem_statement.trim(), 'screenshot');\n }\n } catch (e) {\n // Non-fatal — code hint will fall back to transcript context\n }\n\n // Generate the Rolling Interview Script and send to renderer\n const solution = await this.llmHelper.generateSolution(problemInfo);\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.SOLUTION_SUCCESS, { solution });\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "90914b15c69331063b61895e639fdec43e3c5054": {"t": 1785754413.196566, "n": 9231, "h": "51ba323daa9f8070", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // Initialize self-improving model version manager (background, non-blocking)\n this.llmHelper.initModelVersionManager().catch(err => {\n console.warn('[ProcessingHelper] ModelVersionManager initialization failed (non-critical):', err.message);\n });\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFiles(allPaths);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "60fe8e1b83f5b580c2d57a566fd67d6f25315cd9": {"t": 1785754415.58521, "n": 8967, "h": "2599471d81f3df6f", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFiles(allPaths);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "6137d840a1cd63cebeb09e94ef7c8f9acb149df6": {"t": 1785754417.853575, "n": 9020, "h": "0cb33ef2c6a93c7d", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded keys\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with available keys\");\n ragManager.initializeEmbeddings({\n openaiKey: openaiKey || undefined,\n geminiKey: geminiKey || undefined,\n // ollamaUrl is not fetched in CredentialsManager yet by default, but we pass these keys\n });\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "15cee6157292b0b17f9ce753788d9e924c828551": {"t": 1785754420.1504095, "n": 8839, "h": "2cc6039899e739e7", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded key\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager && geminiKey) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with loaded key\");\n ragManager.initializeEmbeddings(geminiKey);\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n\n // Load Languages\n const sttLanguage = credManager.getSttLanguage();\n const aiResponseLanguage = credManager.getAiResponseLanguage();\n \n if (sttLanguage) {\n this.llmHelper.setSttLanguage(sttLanguage);\n }\n \n if (aiResponseLanguage) {\n this.llmHelper.setAiResponseLanguage(aiResponseLanguage);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "deb7537b3d7104522aa91ca1aea17bca883b710d": {"t": 1785754421.8141654, "n": 8505, "h": "9f90429f89d20237", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport { app } from \"electron\"\n// import dotenv from \"dotenv\" // Removed static import\n\nif (!app.isPackaged) {\n require(\"dotenv\").config()\n}\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded key\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager && geminiKey) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with loaded key\");\n ragManager.initializeEmbeddings(geminiKey);\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "c7a98a5330a9e1b1a6120adf1c4bd1806d47d1b5": {"t": 1785754425.2255244, "n": 8408, "h": "77da55c8d65b3ffa", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport dotenv from \"dotenv\"\n\ndotenv.config()\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded key\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager && geminiKey) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with loaded key\");\n ragManager.initializeEmbeddings(geminiKey);\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n\n // NEW: Load Default Model Config\n const defaultModel = credManager.getDefaultModel();\n if (defaultModel) {\n console.log(`[ProcessingHelper] Loading stored Default Model: ${defaultModel}`);\n const customProviders = credManager.getCustomProviders();\n const curlProviders = credManager.getCurlProviders();\n const allProviders = [...(customProviders || []), ...(curlProviders || [])];\n this.llmHelper.setModel(defaultModel, allProviders);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "f0262eed7e7f89d0c771cab315aa2feff0c31640": {"t": 1785754426.5972471, "n": 7930, "h": "8d154c2a8a394d34", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport dotenv from \"dotenv\"\n\ndotenv.config()\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded key\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager && geminiKey) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with loaded key\");\n ragManager.initializeEmbeddings(geminiKey);\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n\n // CRITICAL: Cleanup stale queue items to prevent \"Chunk not found\" errors\n ragManager.cleanupStaleQueueItems();\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "e8aeb1dff36fbec12ec1588d1194622c0c417f1b": {"t": 1785754427.8751402, "n": 7805, "h": "3b5661570f7dda9e", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport dotenv from \"dotenv\"\n\ndotenv.config()\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n let openaiApiKey = process.env.OPENAI_API_KEY\n let claudeApiKey = process.env.CLAUDE_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey, openaiApiKey, claudeApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n const openaiKey = credManager.getOpenaiApiKey();\n const claudeKey = credManager.getClaudeApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n if (openaiKey) {\n console.log(\"[ProcessingHelper] Loading stored OpenAI API Key from CredentialsManager\");\n this.llmHelper.setOpenaiApiKey(openaiKey);\n }\n\n if (claudeKey) {\n console.log(\"[ProcessingHelper] Loading stored Claude API Key from CredentialsManager\");\n this.llmHelper.setClaudeApiKey(claudeKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded key\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager && geminiKey) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with loaded key\");\n ragManager.initializeEmbeddings(geminiKey);\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "a702e9d21d0ca48004ef5009954d694d4ccb85dd": {"t": 1785754429.4298017, "n": 7223, "h": "c5b525d45335889b", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport dotenv from \"dotenv\"\n\ndotenv.config()\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded key\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager && geminiKey) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with loaded key\");\n ragManager.initializeEmbeddings(geminiKey);\n\n // CRITICAL: Retry pending embeddings now that we have a key\n // This ensures any meetings that failed or were queued during startup get processed\n console.log(\"[ProcessingHelper] Retrying pending embeddings...\");\n ragManager.retryPendingEmbeddings().catch(console.error);\n\n // CRITICAL: Ensure demo meeting has chunks\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "e6794e9d5e5c623293f2fcbdea912e327f5f7ef6": {"t": 1785754431.6712449, "n": 6809, "h": "b3e0a4ce067b0245", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport dotenv from \"dotenv\"\n\ndotenv.config()\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n\n // CRITICAL: Initialize RAGManager (Embeddings) with loaded key\n // This fixes \"RAG unavailable\" in production where process.env is empty\n const ragManager = this.appState.getRAGManager();\n if (ragManager && geminiKey) {\n console.log(\"[ProcessingHelper] Initializing RAGManager embeddings with loaded key\");\n ragManager.initializeEmbeddings(geminiKey);\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "aeccc87318037aa187031e82d6241d4484e6da76": {"t": 1785754433.023189, "n": 6426, "h": "b009fe8407fd48fc", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport { CredentialsManager } from \"./services/CredentialsManager\"\nimport dotenv from \"dotenv\"\n\ndotenv.config()\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n // Try environment first (for development)\n let apiKey = process.env.GEMINI_API_KEY\n let groqApiKey = process.env.GROQ_API_KEY\n\n // Allow initializing without key (will be loaded in loadStoredCredentials or via Settings)\n if (!apiKey) {\n console.warn(\"[ProcessingHelper] GEMINI_API_KEY not found in env. Will try CredentialsManager after ready.\")\n }\n\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey)\n }\n }\n\n /**\n * Load stored credentials from CredentialsManager\n * Should be called after app.whenReady() when CredentialsManager is initialized\n */\n public loadStoredCredentials(): void {\n const credManager = CredentialsManager.getInstance();\n\n const geminiKey = credManager.getGeminiApiKey();\n const groqKey = credManager.getGroqApiKey();\n\n if (geminiKey) {\n console.log(\"[ProcessingHelper] Loading stored Gemini API Key from CredentialsManager\");\n this.llmHelper.setApiKey(geminiKey);\n }\n\n if (groqKey) {\n console.log(\"[ProcessingHelper] Loading stored Groq API Key from CredentialsManager\");\n this.llmHelper.setGroqApiKey(groqKey);\n }\n\n // CRITICAL: Re-initialize IntelligenceManager now that keys are loaded\n // This fixes the issue where buttons don't work in production because of late key loading\n this.appState.getIntelligenceManager().initializeLLMs();\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}, "0a73eb77719d5ab5baf5334f42790ed6c6bf2735": {"t": 1785754436.4371269, "n": 5382, "h": "014ce1f7eff00ed2", "c": "// ProcessingHelper.ts\n\nimport { AppState } from \"./main\"\nimport { LLMHelper } from \"./LLMHelper\"\nimport dotenv from \"dotenv\"\n\ndotenv.config()\n\nconst isDev = process.env.NODE_ENV === \"development\"\nconst isDevTest = process.env.IS_DEV_TEST === \"true\"\nconst MOCK_API_WAIT_TIME = Number(process.env.MOCK_API_WAIT_TIME) || 500\n\nexport class ProcessingHelper {\n private appState: AppState\n private llmHelper: LLMHelper\n private currentProcessingAbortController: AbortController | null = null\n private currentExtraProcessingAbortController: AbortController | null = null\n\n constructor(appState: AppState) {\n this.appState = appState\n\n // Check if user wants to use Ollama\n const useOllama = process.env.USE_OLLAMA === \"true\"\n const ollamaModel = process.env.OLLAMA_MODEL // Don't set default here, let LLMHelper auto-detect\n const ollamaUrl = process.env.OLLAMA_URL || \"http://localhost:11434\"\n\n if (useOllama) {\n // console.log(\"[ProcessingHelper] Initializing with Ollama\")\n this.llmHelper = new LLMHelper(undefined, true, ollamaModel, ollamaUrl)\n } else {\n const apiKey = process.env.GEMINI_API_KEY\n const groqApiKey = process.env.GROQ_API_KEY\n if (!apiKey) {\n throw new Error(\"GEMINI_API_KEY not found in environment variables. Set GEMINI_API_KEY or enable Ollama with USE_OLLAMA=true\")\n }\n // console.log(\"[ProcessingHelper] Initializing with Gemini\")\n this.llmHelper = new LLMHelper(apiKey, false, undefined, undefined, groqApiKey)\n }\n }\n\n public async processScreenshots(): Promise<void> {\n const mainWindow = this.appState.getMainWindow()\n if (!mainWindow) return\n\n const view = this.appState.getView()\n\n if (view === \"queue\") {\n const screenshotQueue = this.appState.getScreenshotHelper().getScreenshotQueue()\n if (screenshotQueue.length === 0) {\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n\n\n const allPaths = this.appState.getScreenshotHelper().getScreenshotQueue();\n const lastPath = allPaths[allPaths.length - 1];\n\n // NEW: Handle screenshot as plain text (like audio)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_START)\n this.appState.setView(\"solutions\")\n this.currentProcessingAbortController = new AbortController()\n try {\n const imageResult = await this.llmHelper.analyzeImageFile(lastPath);\n const problemInfo = {\n problem_statement: imageResult.text,\n input_format: { description: \"Generated from screenshot\", parameters: [] as any[] },\n output_format: { description: \"Generated from screenshot\", type: \"string\", subtype: \"text\" },\n complexity: { time: \"N/A\", space: \"N/A\" },\n test_cases: [] as any[],\n validation_type: \"manual\",\n difficulty: \"custom\"\n };\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.PROBLEM_EXTRACTED, problemInfo);\n this.appState.setProblemInfo(problemInfo);\n } catch (error: any) {\n // console.error(\"Image processing error:\", error)\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.INITIAL_SOLUTION_ERROR, error.message)\n } finally {\n this.currentProcessingAbortController = null\n }\n return;\n } else {\n // Debug mode\n const extraScreenshotQueue = this.appState.getScreenshotHelper().getExtraScreenshotQueue()\n if (extraScreenshotQueue.length === 0) {\n // console.log(\"No extra screenshots to process\")\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.NO_SCREENSHOTS)\n return\n }\n\n mainWindow.webContents.send(this.appState.PROCESSING_EVENTS.DEBUG_START)\n this.currentExtraProcessingAbortController = new AbortController()\n\n try {\n // Get problem info and current solution\n const problemInfo = this.appState.getProblemInfo()\n if (!problemInfo) {\n throw new Error(\"No problem info available\")\n }\n\n // Get current solution from state\n const currentSolution = await this.llmHelper.generateSolution(problemInfo)\n const currentCode = currentSolution.solution.code\n\n // Debug the solution using vision model\n const debugResult = await this.llmHelper.debugSolutionWithImages(\n problemInfo,\n currentCode,\n extraScreenshotQueue\n )\n\n this.appState.setHasDebugged(true)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_SUCCESS,\n debugResult\n )\n\n } catch (error: any) {\n // console.error(\"Debug processing error:\", error)\n mainWindow.webContents.send(\n this.appState.PROCESSING_EVENTS.DEBUG_ERROR,\n error.message\n )\n } finally {\n this.currentExtraProcessingAbortController = null\n }\n }\n }\n\n public cancelOngoingRequests(): void {\n if (this.currentProcessingAbortController) {\n this.currentProcessingAbortController.abort()\n this.currentProcessingAbortController = null\n }\n\n if (this.currentExtraProcessingAbortController) {\n this.currentExtraProcessingAbortController.abort()\n this.currentExtraProcessingAbortController = null\n }\n\n this.appState.setHasDebugged(false)\n }\n\n\n\n public getLLMHelper() {\n return this.llmHelper;\n }\n}\n"}}, "electron/ipcHandlers.ts": {"681d3bde5b2fd4043a4735ac5e519efd6fe7e106": {"t": 1785746052.9386578, "n": 196980, "h": "15fd0cc4e704d98c", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n const safeOn = (\n channel: string,\n listener: (event: any, ...args: any[]) => void,\n ) => {\n ipcMain.removeAllListeners(channel);\n ipcMain.on(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n let _chatStreamId = 0;\n // Keep IDs globally unique for phone/desktop message correlation; supersession is per sender.\n const _chatStreamsBySender = new Map<number, { streamId: number; controller: AbortController }>();\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n let myController: AbortController | null = null;\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n const senderId = event.sender.id;\n const myStreamId = ++_chatStreamId;\n const priorStream = _chatStreamsBySender.get(senderId);\n if (priorStream) {\n try { priorStream.controller.abort(); } catch { /* noop */ }\n }\n myController = new AbortController();\n _chatStreamsBySender.set(senderId, { streamId: myStreamId, controller: myController });\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded for sender ${senderId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing. Pass the abort signal as\n // the trailing arg so the generator stops yielding when this stream\n // is superseded or explicitly cancelled via gemini-chat-stream-stop.\n // The signature accepts a final optional `abortSignal?: AbortSignal`\n // that streamChat extracts from its variadic args.\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n false, // skipModeInjection\n [], // extraDataScopes\n myController.signal,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded for sender ${senderId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n } finally {\n if (myController) {\n const current = _chatStreamsBySender.get(event.sender.id);\n if (current?.controller === myController) {\n _chatStreamsBySender.delete(event.sender.id);\n }\n }\n }\n },\n );\n\n // Renderer-driven cancellation for the sender's active chat stream.\n safeOn('gemini-chat-stream-stop', (event) => {\n const senderId = event.sender.id;\n const stream = _chatStreamsBySender.get(senderId);\n if (stream) {\n try { stream.controller.abort(); } catch { /* noop */ }\n _chatStreamsBySender.delete(senderId);\n }\n });\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Whether this build can perform a real in-place auto-install + relaunch\n // (signed macOS build, or any packaged Windows/Linux build). The renderer\n // uses this to choose the in-app update flow vs. the manual download fallback.\n safeHandle('get-can-auto-update', async () => {\n try {\n return { canAutoUpdate: appState.canAutoUpdate() };\n } catch (err: any) {\n console.error('[IPC] get-can-auto-update failed:', err);\n return { canAutoUpdate: false };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n // Return the AUTHORITATIVE final state so the renderer can reconcile / roll\n // back its optimistic toggle instead of assuming success (RC-2).\n return { success: true, state: appState.getUndetectable() };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n // Authoritative final state for renderer reconciliation (RC-2).\n return { success: true, enabled: appState.getOverlayMousePassthrough() };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled. Hardened against log injection\n // (CWE-117) and rotation thrash by validating types, capping length, stripping\n // control characters, and rate-limiting per sender.\n const FORWARD_LOG_MAX_LEN = 4 * 1024;\n const FORWARD_LOG_RATE_REFILL_MS = 1_000;\n const FORWARD_LOG_RATE_BUCKET = 200;\n const _forwardLogBuckets = new Map<number, { tokens: number; lastRefill: number }>();\n safeOn('forward-log-to-file', (event, level: unknown, msg: unknown) => {\n if (!appState.getVerboseLogging()) return;\n if (typeof level !== 'string' || typeof msg !== 'string') return;\n\n const senderId = event.sender?.id ?? -1;\n const now = Date.now();\n let bucket = _forwardLogBuckets.get(senderId);\n if (!bucket) {\n bucket = { tokens: FORWARD_LOG_RATE_BUCKET, lastRefill: now };\n _forwardLogBuckets.set(senderId, bucket);\n // Reap the bucket when the renderer goes away so the Map cannot grow\n // unbounded across renderer reloads / hidden-window churn.\n try {\n event.sender?.once?.('destroyed', () => {\n _forwardLogBuckets.delete(senderId);\n });\n } catch { /* noop */ }\n } else {\n const elapsed = now - bucket.lastRefill;\n if (elapsed > 0) {\n const refill = Math.floor((elapsed * FORWARD_LOG_RATE_BUCKET) / FORWARD_LOG_RATE_REFILL_MS);\n if (refill > 0) {\n bucket.tokens = Math.min(FORWARD_LOG_RATE_BUCKET, bucket.tokens + refill);\n bucket.lastRefill += Math.floor((refill * FORWARD_LOG_RATE_REFILL_MS) / FORWARD_LOG_RATE_BUCKET);\n }\n }\n }\n if (bucket.tokens <= 0) return;\n bucket.tokens -= 1;\n\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n const sanitized = msg\n .replace(/[\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]/g, ' ')\n .slice(0, FORWARD_LOG_MAX_LEN);\n console.log(`${tag}[${senderId}] ${sanitized}`);\n });\n\n // Meeting interface theme cross-window broadcast. The settings window writes\n // localStorage + sends this IPC; main re-broadcasts to every renderer so the\n // overlay window's React state updates without depending on the same-origin\n // `storage` event (which does not cross BrowserWindow boundaries in Electron).\n // Without this, switching the meeting interface theme while the overlay is\n // hidden leaves it with stale CSS on the next meeting start — manifest as a\n // half-painted UI that requires force-quit.\n // Allowlist must mirror MeetingInterfaceTheme in src/lib/meetingInterfaceTheme.ts.\n // Any string that reaches a renderer via interface-theme:changed ends up in\n // a `data-interface-theme={value}` DOM attribute on the overlay's wrapper\n // div (NativelyInterface.tsx). Without an allowlist, a compromised or buggy\n // renderer could broadcast an arbitrary string — at best CSS selector\n // mismatch (overlay falls back to default), at worst an attribute-injection\n // vector if any consumer ever switched from `setAttribute` to template\n // literals. Hardening the trust boundary at the broadcast point is cheap.\n const VALID_INTERFACE_THEMES = new Set(['default', 'liquid-glass', 'modern']);\n safeOn('interface-theme:set', (_event, theme: string) => {\n if (typeof theme !== 'string' || !VALID_INTERFACE_THEMES.has(theme)) {\n // Truncate + strip control chars before logging — a 64-char payload can\n // still embed \\n/\\r to forge log lines if a future log shipper parses\n // newline-delimited records.\n const safe = typeof theme === 'string'\n ? theme.slice(0, 64).replace(/[\\r\\n\\x00-\\x1f]/g, '?')\n : typeof theme;\n console.warn(`[interface-theme:set] Rejected unknown theme: ${safe}`);\n return;\n }\n BrowserWindow.getAllWindows().forEach((win) => {\n if (win.isDestroyed()) return;\n try {\n win.webContents.send('interface-theme:changed', theme);\n } catch {\n // Renderer may be tearing down between isDestroyed() and send.\n }\n });\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepseek-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepseekApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setDeepseekApiKey(apiKey);\n\n // Cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving DeepSeek API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n const _pricingCache = new Map<string, { data: any; ts: number }>();\n const PRICING_CACHE_TTL_MS = 5 * 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n appState.broadcast('model-changed', defaultModel);\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-pricing', async () => {\n try {\n const cached = _pricingCache.get('pricing');\n if (cached && Date.now() - cached.ts < PRICING_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/pricing', {\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n _pricingCache.set('pricing', { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n // Hoisted out of try so the catch block's stale-cache lookup can reach it.\n let key: string | undefined;\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n // On transient DNS/network failure, serve stale cache rather than showing an error.\n // Railway uses 1s TTL on DNS records, so a momentary resolver hiccup causes ENOTFOUND\n // even when the server is up. Stale quota data is far better than a broken UI.\n const stale = key ? _usageCache.get(key) : undefined;\n if (stale) return { ...stale.data, stale: true };\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n const validateCurlProviderPayload = (provider: unknown): { ok: true } | { ok: false; error: string } => {\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider payload' };\n }\n\n if (!(provider as any).curlCommand.includes('{{TEXT}}')) {\n return { ok: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n if (\n 'responsePath' in provider &&\n typeof (provider as any).responsePath !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider responsePath' };\n }\n\n return { ok: true };\n };\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-custom-provider: invalid payload');\n return { success: false, error: (validation as any).error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-curl-provider: invalid payload');\n return { success: false, error: (validation as any).error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasDeepseekKey: hasKey(creds.deepseekApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n deepseekPreferredModel: creds.deepseekPreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasDeepseekKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n else if (provider === 'deepseek') key = cm.getDeepseekApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n else if (provider === 'deepseek') apiKey = creds.getDeepseekApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n } else if (provider === 'deepseek') {\n response = await axios.post(\n 'https://api.deepseek.com/chat/completions',\n {\n model: 'deepseek-v4-flash',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n sm.set('codexCliServiceTier', normalized.serviceTier);\n sm.set('codexCliModelReasoningEffort', normalized.modelReasoningEffort);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings), update runtime, and notify model UI surfaces\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.5-flash' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n await appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n // UX2: in-app TCC repair button.\n //\n // Runs `tccutil reset Microphone <bundleId>` AND\n // `tccutil reset ScreenCapture <bundleId>` to clear stale macOS TCC entries\n // for Natively. This is the user-facing self-service recovery for the\n // dominant \"permissions appear granted in System Settings but capture is\n // silently zero-filled\" failure mode — which is caused by TCC binding the\n // grant to a binary's cdhash, and the cdhash changing on every rebuild\n // (ad-hoc-signed builds — see AUDIO_RELIABILITY_REPORT.md §3 A1).\n //\n // After tccutil reset, the user MUST force-quit and relaunch the app for\n // the next TCC prompt to appear cleanly. We return the prompt copy so the\n // renderer can show a \"Quit & relaunch\" CTA.\n //\n // Service-name capitalization MATTERS: Apple requires capital `Microphone`\n // and `ScreenCapture` — lowercase fails with \"Invalid Service Name.\" This\n // is the most common implementation bug.\n safeHandle('repair-tcc-permissions', async () => {\n if (process.platform !== 'darwin') {\n return { ok: false, error: 'TCC repair is macOS-only.' };\n }\n\n // Bundle ID resolution: prefer the live Electron app identifier (handles\n // signed packaged builds and dev-mode Electron alike). Falls back to the\n // package.json appId if app.getAppPath() inspection somehow fails.\n let bundleId: string;\n try {\n // app.isPackaged → packaged Info.plist CFBundleIdentifier\n // (== package.json build.appId for electron-builder)\n // !app.isPackaged → 'com.github.Electron' (the dev Electron binary's\n // bundle id; TCC entries land here in dev mode)\n bundleId = app.isPackaged ? 'com.electron.meeting-notes' : 'com.github.Electron';\n } catch {\n bundleId = 'com.electron.meeting-notes';\n }\n\n const { execFile } = require('node:child_process');\n const { promisify } = require('node:util');\n const execFileAsync = promisify(execFile);\n\n const services = ['Microphone', 'ScreenCapture']; // Capital letters REQUIRED.\n const results: Array<{ service: string; ok: boolean; output: string }> = [];\n\n for (const service of services) {\n try {\n // Absolute path — defense-in-depth against PATH shadowing. tccutil is\n // a SIP-protected stock macOS binary at /usr/bin/tccutil; using the\n // bare name would resolve via inherited PATH, which a user-modified\n // shell could in theory redirect.\n const { stdout, stderr } = await execFileAsync('/usr/bin/tccutil', ['reset', service, bundleId], {\n timeout: 5000,\n });\n results.push({ service, ok: true, output: (stdout || stderr || '').toString().trim() });\n console.log(`[IPC] tccutil reset ${service} ${bundleId}: OK`);\n } catch (err: any) {\n const msg = err?.stderr?.toString?.() || err?.message || String(err);\n results.push({ service, ok: false, output: msg.trim() });\n console.warn(`[IPC] tccutil reset ${service} ${bundleId} failed: ${msg}`);\n }\n }\n\n const anyOk = results.some((r) => r.ok);\n return {\n ok: anyOk,\n bundleId,\n results,\n promptRelaunch: anyOk,\n message: anyOk\n ? 'Permissions reset. Quit Natively completely (Cmd+Q) and reopen — macOS will ask you to grant Microphone and Screen Recording again. Approve both to restore audio capture.'\n : `Permission reset failed for ${bundleId}. ${results\n .filter((r) => !r.ok)\n .map((r) => `${r.service}: ${r.output}`)\n .join('; ')}`,\n };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl = parsed.protocol === 'https:';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // Date.now() alone collides when two queries fire in the same ms — the\n // second `set` would overwrite the first AbortController, the first\n // stream would become un-cancellable, and the `finally` `delete` would\n // evict the wrong entry. UUID guarantees uniqueness.\n // (Note: rag:cancel-query only matches `meeting-` and `global` prefixes,\n // so `live-` keys aren't cancellable through that path — pre-existing\n // behaviour, not regressed by this change.)\n const queryKey = `live-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // See live-${...} comment above for why Date.now() alone is unsafe.\n const queryKey = `global-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n if (!global && !meetingId) {\n return { success: false, error: 'meetingId is required' };\n }\n\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n const matchesQuery = global ? key.startsWith('global-') : key.startsWith(`${queryKey}-`);\n if (matchesQuery) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n // Allowlist of file paths the user explicitly selected via profile:select-file.\n // Without this, a compromised renderer could pass arbitrary filesystem paths\n // (e.g. /etc/passwd, ~/.ssh/id_rsa) to the upload handlers and exfiltrate\n // their contents through the knowledge index. Entries expire after 60s.\n const PROFILE_SELECTED_PATH_TTL_MS = 60_000;\n const profileSelectedPaths = new Map<string, number>();\n const normalizeProfilePath = (p: string): string => path.resolve(p);\n const sweepExpiredProfilePaths = (now: number): void => {\n for (const [key, expiresAt] of profileSelectedPaths) {\n if (now > expiresAt) profileSelectedPaths.delete(key);\n }\n };\n const registerSelectedProfilePath = (filePath: string): void => {\n const now = Date.now();\n sweepExpiredProfilePaths(now);\n profileSelectedPaths.set(normalizeProfilePath(filePath), now + PROFILE_SELECTED_PATH_TTL_MS);\n };\n const consumeSelectedProfilePath = (filePath: unknown): string | null => {\n if (typeof filePath !== 'string' || filePath.length === 0) return null;\n const key = normalizeProfilePath(filePath);\n const expiresAt = profileSelectedPaths.get(key);\n if (!expiresAt) return null;\n if (Date.now() > expiresAt) {\n profileSelectedPaths.delete(key);\n return null;\n }\n profileSelectedPaths.delete(key);\n return key;\n };\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-resume rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the resume file.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n const selected = result.filePaths[0];\n registerSelectedProfilePath(selected);\n return { success: true, filePath: selected };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-jd rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the JD file.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${crypto.randomUUID()}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const helper = appState.getWindowHelper();\n const sent = new Set<number>();\n for (const w of [helper.getLauncherWindow(), helper.getOverlayWindow()]) {\n if (!w || w.isDestroyed() || sent.has(w.id)) continue;\n sent.add(w.id);\n try {\n w.webContents.send('global-shortcut', { action: cmd.action });\n } catch {\n // Window is tearing down; keep delivering to any other valid surface.\n }\n }\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "fdd7a123530ecdd4b5fc4fa3e9e8ab3acfce19e5": {"t": 1785754399.9593089, "n": 196980, "h": "15fd0cc4e704d98c", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n const safeOn = (\n channel: string,\n listener: (event: any, ...args: any[]) => void,\n ) => {\n ipcMain.removeAllListeners(channel);\n ipcMain.on(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n let _chatStreamId = 0;\n // Keep IDs globally unique for phone/desktop message correlation; supersession is per sender.\n const _chatStreamsBySender = new Map<number, { streamId: number; controller: AbortController }>();\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n let myController: AbortController | null = null;\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n const senderId = event.sender.id;\n const myStreamId = ++_chatStreamId;\n const priorStream = _chatStreamsBySender.get(senderId);\n if (priorStream) {\n try { priorStream.controller.abort(); } catch { /* noop */ }\n }\n myController = new AbortController();\n _chatStreamsBySender.set(senderId, { streamId: myStreamId, controller: myController });\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded for sender ${senderId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing. Pass the abort signal as\n // the trailing arg so the generator stops yielding when this stream\n // is superseded or explicitly cancelled via gemini-chat-stream-stop.\n // The signature accepts a final optional `abortSignal?: AbortSignal`\n // that streamChat extracts from its variadic args.\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n false, // skipModeInjection\n [], // extraDataScopes\n myController.signal,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded for sender ${senderId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n } finally {\n if (myController) {\n const current = _chatStreamsBySender.get(event.sender.id);\n if (current?.controller === myController) {\n _chatStreamsBySender.delete(event.sender.id);\n }\n }\n }\n },\n );\n\n // Renderer-driven cancellation for the sender's active chat stream.\n safeOn('gemini-chat-stream-stop', (event) => {\n const senderId = event.sender.id;\n const stream = _chatStreamsBySender.get(senderId);\n if (stream) {\n try { stream.controller.abort(); } catch { /* noop */ }\n _chatStreamsBySender.delete(senderId);\n }\n });\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Whether this build can perform a real in-place auto-install + relaunch\n // (signed macOS build, or any packaged Windows/Linux build). The renderer\n // uses this to choose the in-app update flow vs. the manual download fallback.\n safeHandle('get-can-auto-update', async () => {\n try {\n return { canAutoUpdate: appState.canAutoUpdate() };\n } catch (err: any) {\n console.error('[IPC] get-can-auto-update failed:', err);\n return { canAutoUpdate: false };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n // Return the AUTHORITATIVE final state so the renderer can reconcile / roll\n // back its optimistic toggle instead of assuming success (RC-2).\n return { success: true, state: appState.getUndetectable() };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n // Authoritative final state for renderer reconciliation (RC-2).\n return { success: true, enabled: appState.getOverlayMousePassthrough() };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled. Hardened against log injection\n // (CWE-117) and rotation thrash by validating types, capping length, stripping\n // control characters, and rate-limiting per sender.\n const FORWARD_LOG_MAX_LEN = 4 * 1024;\n const FORWARD_LOG_RATE_REFILL_MS = 1_000;\n const FORWARD_LOG_RATE_BUCKET = 200;\n const _forwardLogBuckets = new Map<number, { tokens: number; lastRefill: number }>();\n safeOn('forward-log-to-file', (event, level: unknown, msg: unknown) => {\n if (!appState.getVerboseLogging()) return;\n if (typeof level !== 'string' || typeof msg !== 'string') return;\n\n const senderId = event.sender?.id ?? -1;\n const now = Date.now();\n let bucket = _forwardLogBuckets.get(senderId);\n if (!bucket) {\n bucket = { tokens: FORWARD_LOG_RATE_BUCKET, lastRefill: now };\n _forwardLogBuckets.set(senderId, bucket);\n // Reap the bucket when the renderer goes away so the Map cannot grow\n // unbounded across renderer reloads / hidden-window churn.\n try {\n event.sender?.once?.('destroyed', () => {\n _forwardLogBuckets.delete(senderId);\n });\n } catch { /* noop */ }\n } else {\n const elapsed = now - bucket.lastRefill;\n if (elapsed > 0) {\n const refill = Math.floor((elapsed * FORWARD_LOG_RATE_BUCKET) / FORWARD_LOG_RATE_REFILL_MS);\n if (refill > 0) {\n bucket.tokens = Math.min(FORWARD_LOG_RATE_BUCKET, bucket.tokens + refill);\n bucket.lastRefill += Math.floor((refill * FORWARD_LOG_RATE_REFILL_MS) / FORWARD_LOG_RATE_BUCKET);\n }\n }\n }\n if (bucket.tokens <= 0) return;\n bucket.tokens -= 1;\n\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n const sanitized = msg\n .replace(/[\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]/g, ' ')\n .slice(0, FORWARD_LOG_MAX_LEN);\n console.log(`${tag}[${senderId}] ${sanitized}`);\n });\n\n // Meeting interface theme cross-window broadcast. The settings window writes\n // localStorage + sends this IPC; main re-broadcasts to every renderer so the\n // overlay window's React state updates without depending on the same-origin\n // `storage` event (which does not cross BrowserWindow boundaries in Electron).\n // Without this, switching the meeting interface theme while the overlay is\n // hidden leaves it with stale CSS on the next meeting start — manifest as a\n // half-painted UI that requires force-quit.\n // Allowlist must mirror MeetingInterfaceTheme in src/lib/meetingInterfaceTheme.ts.\n // Any string that reaches a renderer via interface-theme:changed ends up in\n // a `data-interface-theme={value}` DOM attribute on the overlay's wrapper\n // div (NativelyInterface.tsx). Without an allowlist, a compromised or buggy\n // renderer could broadcast an arbitrary string — at best CSS selector\n // mismatch (overlay falls back to default), at worst an attribute-injection\n // vector if any consumer ever switched from `setAttribute` to template\n // literals. Hardening the trust boundary at the broadcast point is cheap.\n const VALID_INTERFACE_THEMES = new Set(['default', 'liquid-glass', 'modern']);\n safeOn('interface-theme:set', (_event, theme: string) => {\n if (typeof theme !== 'string' || !VALID_INTERFACE_THEMES.has(theme)) {\n // Truncate + strip control chars before logging — a 64-char payload can\n // still embed \\n/\\r to forge log lines if a future log shipper parses\n // newline-delimited records.\n const safe = typeof theme === 'string'\n ? theme.slice(0, 64).replace(/[\\r\\n\\x00-\\x1f]/g, '?')\n : typeof theme;\n console.warn(`[interface-theme:set] Rejected unknown theme: ${safe}`);\n return;\n }\n BrowserWindow.getAllWindows().forEach((win) => {\n if (win.isDestroyed()) return;\n try {\n win.webContents.send('interface-theme:changed', theme);\n } catch {\n // Renderer may be tearing down between isDestroyed() and send.\n }\n });\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepseek-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepseekApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setDeepseekApiKey(apiKey);\n\n // Cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving DeepSeek API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n const _pricingCache = new Map<string, { data: any; ts: number }>();\n const PRICING_CACHE_TTL_MS = 5 * 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n appState.broadcast('model-changed', defaultModel);\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-pricing', async () => {\n try {\n const cached = _pricingCache.get('pricing');\n if (cached && Date.now() - cached.ts < PRICING_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/pricing', {\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n _pricingCache.set('pricing', { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n // Hoisted out of try so the catch block's stale-cache lookup can reach it.\n let key: string | undefined;\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n // On transient DNS/network failure, serve stale cache rather than showing an error.\n // Railway uses 1s TTL on DNS records, so a momentary resolver hiccup causes ENOTFOUND\n // even when the server is up. Stale quota data is far better than a broken UI.\n const stale = key ? _usageCache.get(key) : undefined;\n if (stale) return { ...stale.data, stale: true };\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n const validateCurlProviderPayload = (provider: unknown): { ok: true } | { ok: false; error: string } => {\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider payload' };\n }\n\n if (!(provider as any).curlCommand.includes('{{TEXT}}')) {\n return { ok: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n if (\n 'responsePath' in provider &&\n typeof (provider as any).responsePath !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider responsePath' };\n }\n\n return { ok: true };\n };\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-custom-provider: invalid payload');\n return { success: false, error: (validation as any).error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-curl-provider: invalid payload');\n return { success: false, error: (validation as any).error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasDeepseekKey: hasKey(creds.deepseekApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n deepseekPreferredModel: creds.deepseekPreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasDeepseekKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n else if (provider === 'deepseek') key = cm.getDeepseekApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n else if (provider === 'deepseek') apiKey = creds.getDeepseekApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n } else if (provider === 'deepseek') {\n response = await axios.post(\n 'https://api.deepseek.com/chat/completions',\n {\n model: 'deepseek-v4-flash',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n sm.set('codexCliServiceTier', normalized.serviceTier);\n sm.set('codexCliModelReasoningEffort', normalized.modelReasoningEffort);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings), update runtime, and notify model UI surfaces\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.5-flash' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n await appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n // UX2: in-app TCC repair button.\n //\n // Runs `tccutil reset Microphone <bundleId>` AND\n // `tccutil reset ScreenCapture <bundleId>` to clear stale macOS TCC entries\n // for Natively. This is the user-facing self-service recovery for the\n // dominant \"permissions appear granted in System Settings but capture is\n // silently zero-filled\" failure mode — which is caused by TCC binding the\n // grant to a binary's cdhash, and the cdhash changing on every rebuild\n // (ad-hoc-signed builds — see AUDIO_RELIABILITY_REPORT.md §3 A1).\n //\n // After tccutil reset, the user MUST force-quit and relaunch the app for\n // the next TCC prompt to appear cleanly. We return the prompt copy so the\n // renderer can show a \"Quit & relaunch\" CTA.\n //\n // Service-name capitalization MATTERS: Apple requires capital `Microphone`\n // and `ScreenCapture` — lowercase fails with \"Invalid Service Name.\" This\n // is the most common implementation bug.\n safeHandle('repair-tcc-permissions', async () => {\n if (process.platform !== 'darwin') {\n return { ok: false, error: 'TCC repair is macOS-only.' };\n }\n\n // Bundle ID resolution: prefer the live Electron app identifier (handles\n // signed packaged builds and dev-mode Electron alike). Falls back to the\n // package.json appId if app.getAppPath() inspection somehow fails.\n let bundleId: string;\n try {\n // app.isPackaged → packaged Info.plist CFBundleIdentifier\n // (== package.json build.appId for electron-builder)\n // !app.isPackaged → 'com.github.Electron' (the dev Electron binary's\n // bundle id; TCC entries land here in dev mode)\n bundleId = app.isPackaged ? 'com.electron.meeting-notes' : 'com.github.Electron';\n } catch {\n bundleId = 'com.electron.meeting-notes';\n }\n\n const { execFile } = require('node:child_process');\n const { promisify } = require('node:util');\n const execFileAsync = promisify(execFile);\n\n const services = ['Microphone', 'ScreenCapture']; // Capital letters REQUIRED.\n const results: Array<{ service: string; ok: boolean; output: string }> = [];\n\n for (const service of services) {\n try {\n // Absolute path — defense-in-depth against PATH shadowing. tccutil is\n // a SIP-protected stock macOS binary at /usr/bin/tccutil; using the\n // bare name would resolve via inherited PATH, which a user-modified\n // shell could in theory redirect.\n const { stdout, stderr } = await execFileAsync('/usr/bin/tccutil', ['reset', service, bundleId], {\n timeout: 5000,\n });\n results.push({ service, ok: true, output: (stdout || stderr || '').toString().trim() });\n console.log(`[IPC] tccutil reset ${service} ${bundleId}: OK`);\n } catch (err: any) {\n const msg = err?.stderr?.toString?.() || err?.message || String(err);\n results.push({ service, ok: false, output: msg.trim() });\n console.warn(`[IPC] tccutil reset ${service} ${bundleId} failed: ${msg}`);\n }\n }\n\n const anyOk = results.some((r) => r.ok);\n return {\n ok: anyOk,\n bundleId,\n results,\n promptRelaunch: anyOk,\n message: anyOk\n ? 'Permissions reset. Quit Natively completely (Cmd+Q) and reopen — macOS will ask you to grant Microphone and Screen Recording again. Approve both to restore audio capture.'\n : `Permission reset failed for ${bundleId}. ${results\n .filter((r) => !r.ok)\n .map((r) => `${r.service}: ${r.output}`)\n .join('; ')}`,\n };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl = parsed.protocol === 'https:';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // Date.now() alone collides when two queries fire in the same ms — the\n // second `set` would overwrite the first AbortController, the first\n // stream would become un-cancellable, and the `finally` `delete` would\n // evict the wrong entry. UUID guarantees uniqueness.\n // (Note: rag:cancel-query only matches `meeting-` and `global` prefixes,\n // so `live-` keys aren't cancellable through that path — pre-existing\n // behaviour, not regressed by this change.)\n const queryKey = `live-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // See live-${...} comment above for why Date.now() alone is unsafe.\n const queryKey = `global-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n if (!global && !meetingId) {\n return { success: false, error: 'meetingId is required' };\n }\n\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n const matchesQuery = global ? key.startsWith('global-') : key.startsWith(`${queryKey}-`);\n if (matchesQuery) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n // Allowlist of file paths the user explicitly selected via profile:select-file.\n // Without this, a compromised renderer could pass arbitrary filesystem paths\n // (e.g. /etc/passwd, ~/.ssh/id_rsa) to the upload handlers and exfiltrate\n // their contents through the knowledge index. Entries expire after 60s.\n const PROFILE_SELECTED_PATH_TTL_MS = 60_000;\n const profileSelectedPaths = new Map<string, number>();\n const normalizeProfilePath = (p: string): string => path.resolve(p);\n const sweepExpiredProfilePaths = (now: number): void => {\n for (const [key, expiresAt] of profileSelectedPaths) {\n if (now > expiresAt) profileSelectedPaths.delete(key);\n }\n };\n const registerSelectedProfilePath = (filePath: string): void => {\n const now = Date.now();\n sweepExpiredProfilePaths(now);\n profileSelectedPaths.set(normalizeProfilePath(filePath), now + PROFILE_SELECTED_PATH_TTL_MS);\n };\n const consumeSelectedProfilePath = (filePath: unknown): string | null => {\n if (typeof filePath !== 'string' || filePath.length === 0) return null;\n const key = normalizeProfilePath(filePath);\n const expiresAt = profileSelectedPaths.get(key);\n if (!expiresAt) return null;\n if (Date.now() > expiresAt) {\n profileSelectedPaths.delete(key);\n return null;\n }\n profileSelectedPaths.delete(key);\n return key;\n };\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-resume rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the resume file.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n const selected = result.filePaths[0];\n registerSelectedProfilePath(selected);\n return { success: true, filePath: selected };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-jd rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the JD file.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${crypto.randomUUID()}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const helper = appState.getWindowHelper();\n const sent = new Set<number>();\n for (const w of [helper.getLauncherWindow(), helper.getOverlayWindow()]) {\n if (!w || w.isDestroyed() || sent.has(w.id)) continue;\n sent.add(w.id);\n try {\n w.webContents.send('global-shortcut', { action: cmd.action });\n } catch {\n // Window is tearing down; keep delivering to any other valid surface.\n }\n }\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "a801d624d49463b96583bf18ab0cae254db1786a": {"t": 1785754401.9350138, "n": 196487, "h": "abc04a11aeb861e3", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n const safeOn = (\n channel: string,\n listener: (event: any, ...args: any[]) => void,\n ) => {\n ipcMain.removeAllListeners(channel);\n ipcMain.on(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n let _chatStreamId = 0;\n // Keep IDs globally unique for phone/desktop message correlation; supersession is per sender.\n const _chatStreamsBySender = new Map<number, { streamId: number; controller: AbortController }>();\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n let myController: AbortController | null = null;\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n const senderId = event.sender.id;\n const myStreamId = ++_chatStreamId;\n const priorStream = _chatStreamsBySender.get(senderId);\n if (priorStream) {\n try { priorStream.controller.abort(); } catch { /* noop */ }\n }\n myController = new AbortController();\n _chatStreamsBySender.set(senderId, { streamId: myStreamId, controller: myController });\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded for sender ${senderId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing. Pass the abort signal as\n // the trailing arg so the generator stops yielding when this stream\n // is superseded or explicitly cancelled via gemini-chat-stream-stop.\n // The signature accepts a final optional `abortSignal?: AbortSignal`\n // that streamChat extracts from its variadic args.\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n false, // skipModeInjection\n [], // extraDataScopes\n myController.signal,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded for sender ${senderId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n } finally {\n if (myController) {\n const current = _chatStreamsBySender.get(event.sender.id);\n if (current?.controller === myController) {\n _chatStreamsBySender.delete(event.sender.id);\n }\n }\n }\n },\n );\n\n // Renderer-driven cancellation for the sender's active chat stream.\n safeOn('gemini-chat-stream-stop', (event) => {\n const senderId = event.sender.id;\n const stream = _chatStreamsBySender.get(senderId);\n if (stream) {\n try { stream.controller.abort(); } catch { /* noop */ }\n _chatStreamsBySender.delete(senderId);\n }\n });\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Whether this build can perform a real in-place auto-install + relaunch\n // (signed macOS build, or any packaged Windows/Linux build). The renderer\n // uses this to choose the in-app update flow vs. the manual download fallback.\n safeHandle('get-can-auto-update', async () => {\n try {\n return { canAutoUpdate: appState.canAutoUpdate() };\n } catch (err: any) {\n console.error('[IPC] get-can-auto-update failed:', err);\n return { canAutoUpdate: false };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n // Return the AUTHORITATIVE final state so the renderer can reconcile / roll\n // back its optimistic toggle instead of assuming success (RC-2).\n return { success: true, state: appState.getUndetectable() };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n // Authoritative final state for renderer reconciliation (RC-2).\n return { success: true, enabled: appState.getOverlayMousePassthrough() };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled. Hardened against log injection\n // (CWE-117) and rotation thrash by validating types, capping length, stripping\n // control characters, and rate-limiting per sender.\n const FORWARD_LOG_MAX_LEN = 4 * 1024;\n const FORWARD_LOG_RATE_REFILL_MS = 1_000;\n const FORWARD_LOG_RATE_BUCKET = 200;\n const _forwardLogBuckets = new Map<number, { tokens: number; lastRefill: number }>();\n safeOn('forward-log-to-file', (event, level: unknown, msg: unknown) => {\n if (!appState.getVerboseLogging()) return;\n if (typeof level !== 'string' || typeof msg !== 'string') return;\n\n const senderId = event.sender?.id ?? -1;\n const now = Date.now();\n let bucket = _forwardLogBuckets.get(senderId);\n if (!bucket) {\n bucket = { tokens: FORWARD_LOG_RATE_BUCKET, lastRefill: now };\n _forwardLogBuckets.set(senderId, bucket);\n // Reap the bucket when the renderer goes away so the Map cannot grow\n // unbounded across renderer reloads / hidden-window churn.\n try {\n event.sender?.once?.('destroyed', () => {\n _forwardLogBuckets.delete(senderId);\n });\n } catch { /* noop */ }\n } else {\n const elapsed = now - bucket.lastRefill;\n if (elapsed > 0) {\n const refill = Math.floor((elapsed * FORWARD_LOG_RATE_BUCKET) / FORWARD_LOG_RATE_REFILL_MS);\n if (refill > 0) {\n bucket.tokens = Math.min(FORWARD_LOG_RATE_BUCKET, bucket.tokens + refill);\n bucket.lastRefill += Math.floor((refill * FORWARD_LOG_RATE_REFILL_MS) / FORWARD_LOG_RATE_BUCKET);\n }\n }\n }\n if (bucket.tokens <= 0) return;\n bucket.tokens -= 1;\n\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n const sanitized = msg\n .replace(/[\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]/g, ' ')\n .slice(0, FORWARD_LOG_MAX_LEN);\n console.log(`${tag}[${senderId}] ${sanitized}`);\n });\n\n // Meeting interface theme cross-window broadcast. The settings window writes\n // localStorage + sends this IPC; main re-broadcasts to every renderer so the\n // overlay window's React state updates without depending on the same-origin\n // `storage` event (which does not cross BrowserWindow boundaries in Electron).\n // Without this, switching the meeting interface theme while the overlay is\n // hidden leaves it with stale CSS on the next meeting start — manifest as a\n // half-painted UI that requires force-quit.\n // Allowlist must mirror MeetingInterfaceTheme in src/lib/meetingInterfaceTheme.ts.\n // Any string that reaches a renderer via interface-theme:changed ends up in\n // a `data-interface-theme={value}` DOM attribute on the overlay's wrapper\n // div (NativelyInterface.tsx). Without an allowlist, a compromised or buggy\n // renderer could broadcast an arbitrary string — at best CSS selector\n // mismatch (overlay falls back to default), at worst an attribute-injection\n // vector if any consumer ever switched from `setAttribute` to template\n // literals. Hardening the trust boundary at the broadcast point is cheap.\n const VALID_INTERFACE_THEMES = new Set(['default', 'liquid-glass', 'modern']);\n safeOn('interface-theme:set', (_event, theme: string) => {\n if (typeof theme !== 'string' || !VALID_INTERFACE_THEMES.has(theme)) {\n // Truncate + strip control chars before logging — a 64-char payload can\n // still embed \\n/\\r to forge log lines if a future log shipper parses\n // newline-delimited records.\n const safe = typeof theme === 'string'\n ? theme.slice(0, 64).replace(/[\\r\\n\\x00-\\x1f]/g, '?')\n : typeof theme;\n console.warn(`[interface-theme:set] Rejected unknown theme: ${safe}`);\n return;\n }\n BrowserWindow.getAllWindows().forEach((win) => {\n if (win.isDestroyed()) return;\n try {\n win.webContents.send('interface-theme:changed', theme);\n } catch {\n // Renderer may be tearing down between isDestroyed() and send.\n }\n });\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepseek-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepseekApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setDeepseekApiKey(apiKey);\n\n // Cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving DeepSeek API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n const _pricingCache = new Map<string, { data: any; ts: number }>();\n const PRICING_CACHE_TTL_MS = 5 * 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n appState.broadcast('model-changed', defaultModel);\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-pricing', async () => {\n try {\n const cached = _pricingCache.get('pricing');\n if (cached && Date.now() - cached.ts < PRICING_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/pricing', {\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n _pricingCache.set('pricing', { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n const validateCurlProviderPayload = (provider: unknown): { ok: true } | { ok: false; error: string } => {\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider payload' };\n }\n\n if (!(provider as any).curlCommand.includes('{{TEXT}}')) {\n return { ok: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n if (\n 'responsePath' in provider &&\n typeof (provider as any).responsePath !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider responsePath' };\n }\n\n return { ok: true };\n };\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-custom-provider: invalid payload');\n return { success: false, error: (validation as any).error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-curl-provider: invalid payload');\n return { success: false, error: (validation as any).error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasDeepseekKey: hasKey(creds.deepseekApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n deepseekPreferredModel: creds.deepseekPreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasDeepseekKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n else if (provider === 'deepseek') key = cm.getDeepseekApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n else if (provider === 'deepseek') apiKey = creds.getDeepseekApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n } else if (provider === 'deepseek') {\n response = await axios.post(\n 'https://api.deepseek.com/chat/completions',\n {\n model: 'deepseek-v4-flash',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n sm.set('codexCliServiceTier', normalized.serviceTier);\n sm.set('codexCliModelReasoningEffort', normalized.modelReasoningEffort);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings), update runtime, and notify model UI surfaces\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.5-flash' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n await appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n // UX2: in-app TCC repair button.\n //\n // Runs `tccutil reset Microphone <bundleId>` AND\n // `tccutil reset ScreenCapture <bundleId>` to clear stale macOS TCC entries\n // for Natively. This is the user-facing self-service recovery for the\n // dominant \"permissions appear granted in System Settings but capture is\n // silently zero-filled\" failure mode — which is caused by TCC binding the\n // grant to a binary's cdhash, and the cdhash changing on every rebuild\n // (ad-hoc-signed builds — see AUDIO_RELIABILITY_REPORT.md §3 A1).\n //\n // After tccutil reset, the user MUST force-quit and relaunch the app for\n // the next TCC prompt to appear cleanly. We return the prompt copy so the\n // renderer can show a \"Quit & relaunch\" CTA.\n //\n // Service-name capitalization MATTERS: Apple requires capital `Microphone`\n // and `ScreenCapture` — lowercase fails with \"Invalid Service Name.\" This\n // is the most common implementation bug.\n safeHandle('repair-tcc-permissions', async () => {\n if (process.platform !== 'darwin') {\n return { ok: false, error: 'TCC repair is macOS-only.' };\n }\n\n // Bundle ID resolution: prefer the live Electron app identifier (handles\n // signed packaged builds and dev-mode Electron alike). Falls back to the\n // package.json appId if app.getAppPath() inspection somehow fails.\n let bundleId: string;\n try {\n // app.isPackaged → packaged Info.plist CFBundleIdentifier\n // (== package.json build.appId for electron-builder)\n // !app.isPackaged → 'com.github.Electron' (the dev Electron binary's\n // bundle id; TCC entries land here in dev mode)\n bundleId = app.isPackaged ? 'com.electron.meeting-notes' : 'com.github.Electron';\n } catch {\n bundleId = 'com.electron.meeting-notes';\n }\n\n const { execFile } = require('node:child_process');\n const { promisify } = require('node:util');\n const execFileAsync = promisify(execFile);\n\n const services = ['Microphone', 'ScreenCapture']; // Capital letters REQUIRED.\n const results: Array<{ service: string; ok: boolean; output: string }> = [];\n\n for (const service of services) {\n try {\n // Absolute path — defense-in-depth against PATH shadowing. tccutil is\n // a SIP-protected stock macOS binary at /usr/bin/tccutil; using the\n // bare name would resolve via inherited PATH, which a user-modified\n // shell could in theory redirect.\n const { stdout, stderr } = await execFileAsync('/usr/bin/tccutil', ['reset', service, bundleId], {\n timeout: 5000,\n });\n results.push({ service, ok: true, output: (stdout || stderr || '').toString().trim() });\n console.log(`[IPC] tccutil reset ${service} ${bundleId}: OK`);\n } catch (err: any) {\n const msg = err?.stderr?.toString?.() || err?.message || String(err);\n results.push({ service, ok: false, output: msg.trim() });\n console.warn(`[IPC] tccutil reset ${service} ${bundleId} failed: ${msg}`);\n }\n }\n\n const anyOk = results.some((r) => r.ok);\n return {\n ok: anyOk,\n bundleId,\n results,\n promptRelaunch: anyOk,\n message: anyOk\n ? 'Permissions reset. Quit Natively completely (Cmd+Q) and reopen — macOS will ask you to grant Microphone and Screen Recording again. Approve both to restore audio capture.'\n : `Permission reset failed for ${bundleId}. ${results\n .filter((r) => !r.ok)\n .map((r) => `${r.service}: ${r.output}`)\n .join('; ')}`,\n };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl = parsed.protocol === 'https:';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // Date.now() alone collides when two queries fire in the same ms — the\n // second `set` would overwrite the first AbortController, the first\n // stream would become un-cancellable, and the `finally` `delete` would\n // evict the wrong entry. UUID guarantees uniqueness.\n // (Note: rag:cancel-query only matches `meeting-` and `global` prefixes,\n // so `live-` keys aren't cancellable through that path — pre-existing\n // behaviour, not regressed by this change.)\n const queryKey = `live-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // See live-${...} comment above for why Date.now() alone is unsafe.\n const queryKey = `global-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n if (!global && !meetingId) {\n return { success: false, error: 'meetingId is required' };\n }\n\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n const matchesQuery = global ? key.startsWith('global-') : key.startsWith(`${queryKey}-`);\n if (matchesQuery) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n // Allowlist of file paths the user explicitly selected via profile:select-file.\n // Without this, a compromised renderer could pass arbitrary filesystem paths\n // (e.g. /etc/passwd, ~/.ssh/id_rsa) to the upload handlers and exfiltrate\n // their contents through the knowledge index. Entries expire after 60s.\n const PROFILE_SELECTED_PATH_TTL_MS = 60_000;\n const profileSelectedPaths = new Map<string, number>();\n const normalizeProfilePath = (p: string): string => path.resolve(p);\n const sweepExpiredProfilePaths = (now: number): void => {\n for (const [key, expiresAt] of profileSelectedPaths) {\n if (now > expiresAt) profileSelectedPaths.delete(key);\n }\n };\n const registerSelectedProfilePath = (filePath: string): void => {\n const now = Date.now();\n sweepExpiredProfilePaths(now);\n profileSelectedPaths.set(normalizeProfilePath(filePath), now + PROFILE_SELECTED_PATH_TTL_MS);\n };\n const consumeSelectedProfilePath = (filePath: unknown): string | null => {\n if (typeof filePath !== 'string' || filePath.length === 0) return null;\n const key = normalizeProfilePath(filePath);\n const expiresAt = profileSelectedPaths.get(key);\n if (!expiresAt) return null;\n if (Date.now() > expiresAt) {\n profileSelectedPaths.delete(key);\n return null;\n }\n profileSelectedPaths.delete(key);\n return key;\n };\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-resume rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the resume file.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n const selected = result.filePaths[0];\n registerSelectedProfilePath(selected);\n return { success: true, filePath: selected };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-jd rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the JD file.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${crypto.randomUUID()}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const helper = appState.getWindowHelper();\n const sent = new Set<number>();\n for (const w of [helper.getLauncherWindow(), helper.getOverlayWindow()]) {\n if (!w || w.isDestroyed() || sent.has(w.id)) continue;\n sent.add(w.id);\n try {\n w.webContents.send('global-shortcut', { action: cmd.action });\n } catch {\n // Window is tearing down; keep delivering to any other valid surface.\n }\n }\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "f1119575ec6cb92e3e29873e2c66ebf48ac98d0e": {"t": 1785754404.9072435, "n": 196495, "h": "9e0e1abb583d7628", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n const safeOn = (\n channel: string,\n listener: (event: any, ...args: any[]) => void,\n ) => {\n ipcMain.removeAllListeners(channel);\n ipcMain.on(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n let _chatStreamId = 0;\n // Keep IDs globally unique for phone/desktop message correlation; supersession is per sender.\n const _chatStreamsBySender = new Map<number, { streamId: number; controller: AbortController }>();\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n let myController: AbortController | null = null;\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n const senderId = event.sender.id;\n const myStreamId = ++_chatStreamId;\n const priorStream = _chatStreamsBySender.get(senderId);\n if (priorStream) {\n try { priorStream.controller.abort(); } catch { /* noop */ }\n }\n myController = new AbortController();\n _chatStreamsBySender.set(senderId, { streamId: myStreamId, controller: myController });\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded for sender ${senderId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing. Pass the abort signal as\n // the trailing arg so the generator stops yielding when this stream\n // is superseded or explicitly cancelled via gemini-chat-stream-stop.\n // The signature accepts a final optional `abortSignal?: AbortSignal`\n // that streamChat extracts from its variadic args.\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n false, // skipModeInjection\n [], // extraDataScopes\n myController.signal,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded for sender ${senderId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n } finally {\n if (myController) {\n const current = _chatStreamsBySender.get(event.sender.id);\n if (current?.controller === myController) {\n _chatStreamsBySender.delete(event.sender.id);\n }\n }\n }\n },\n );\n\n // Renderer-driven cancellation for the sender's active chat stream.\n safeOn('gemini-chat-stream-stop', (event) => {\n const senderId = event.sender.id;\n const stream = _chatStreamsBySender.get(senderId);\n if (stream) {\n try { stream.controller.abort(); } catch { /* noop */ }\n _chatStreamsBySender.delete(senderId);\n }\n });\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Whether this build can perform a real in-place auto-install + relaunch\n // (signed macOS build, or any packaged Windows/Linux build). The renderer\n // uses this to choose the in-app update flow vs. the manual download fallback.\n safeHandle('get-can-auto-update', async () => {\n try {\n return { canAutoUpdate: appState.canAutoUpdate() };\n } catch (err: any) {\n console.error('[IPC] get-can-auto-update failed:', err);\n return { canAutoUpdate: false };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n // Return the AUTHORITATIVE final state so the renderer can reconcile / roll\n // back its optimistic toggle instead of assuming success (RC-2).\n return { success: true, state: appState.getUndetectable() };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n // Authoritative final state for renderer reconciliation (RC-2).\n return { success: true, enabled: appState.getOverlayMousePassthrough() };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled. Hardened against log injection\n // (CWE-117) and rotation thrash by validating types, capping length, stripping\n // control characters, and rate-limiting per sender.\n const FORWARD_LOG_MAX_LEN = 4 * 1024;\n const FORWARD_LOG_RATE_REFILL_MS = 1_000;\n const FORWARD_LOG_RATE_BUCKET = 200;\n const _forwardLogBuckets = new Map<number, { tokens: number; lastRefill: number }>();\n safeOn('forward-log-to-file', (event, level: unknown, msg: unknown) => {\n if (!appState.getVerboseLogging()) return;\n if (typeof level !== 'string' || typeof msg !== 'string') return;\n\n const senderId = event.sender?.id ?? -1;\n const now = Date.now();\n let bucket = _forwardLogBuckets.get(senderId);\n if (!bucket) {\n bucket = { tokens: FORWARD_LOG_RATE_BUCKET, lastRefill: now };\n _forwardLogBuckets.set(senderId, bucket);\n // Reap the bucket when the renderer goes away so the Map cannot grow\n // unbounded across renderer reloads / hidden-window churn.\n try {\n event.sender?.once?.('destroyed', () => {\n _forwardLogBuckets.delete(senderId);\n });\n } catch { /* noop */ }\n } else {\n const elapsed = now - bucket.lastRefill;\n if (elapsed > 0) {\n const refill = Math.floor((elapsed * FORWARD_LOG_RATE_BUCKET) / FORWARD_LOG_RATE_REFILL_MS);\n if (refill > 0) {\n bucket.tokens = Math.min(FORWARD_LOG_RATE_BUCKET, bucket.tokens + refill);\n bucket.lastRefill += Math.floor((refill * FORWARD_LOG_RATE_REFILL_MS) / FORWARD_LOG_RATE_BUCKET);\n }\n }\n }\n if (bucket.tokens <= 0) return;\n bucket.tokens -= 1;\n\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n const sanitized = msg\n .replace(/[\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]/g, ' ')\n .slice(0, FORWARD_LOG_MAX_LEN);\n console.log(`${tag}[${senderId}] ${sanitized}`);\n });\n\n // Meeting interface theme cross-window broadcast. The settings window writes\n // localStorage + sends this IPC; main re-broadcasts to every renderer so the\n // overlay window's React state updates without depending on the same-origin\n // `storage` event (which does not cross BrowserWindow boundaries in Electron).\n // Without this, switching the meeting interface theme while the overlay is\n // hidden leaves it with stale CSS on the next meeting start — manifest as a\n // half-painted UI that requires force-quit.\n // Allowlist must mirror MeetingInterfaceTheme in src/lib/meetingInterfaceTheme.ts.\n // Any string that reaches a renderer via interface-theme:changed ends up in\n // a `data-interface-theme={value}` DOM attribute on the overlay's wrapper\n // div (NativelyInterface.tsx). Without an allowlist, a compromised or buggy\n // renderer could broadcast an arbitrary string — at best CSS selector\n // mismatch (overlay falls back to default), at worst an attribute-injection\n // vector if any consumer ever switched from `setAttribute` to template\n // literals. Hardening the trust boundary at the broadcast point is cheap.\n const VALID_INTERFACE_THEMES = new Set(['default', 'liquid-glass', 'modern']);\n safeOn('interface-theme:set', (_event, theme: string) => {\n if (typeof theme !== 'string' || !VALID_INTERFACE_THEMES.has(theme)) {\n // Truncate + strip control chars before logging — a 64-char payload can\n // still embed \\n/\\r to forge log lines if a future log shipper parses\n // newline-delimited records.\n const safe = typeof theme === 'string'\n ? theme.slice(0, 64).replace(/[\\r\\n\\x00-\\x1f]/g, '?')\n : typeof theme;\n console.warn(`[interface-theme:set] Rejected unknown theme: ${safe}`);\n return;\n }\n BrowserWindow.getAllWindows().forEach((win) => {\n if (win.isDestroyed()) return;\n try {\n win.webContents.send('interface-theme:changed', theme);\n } catch {\n // Renderer may be tearing down between isDestroyed() and send.\n }\n });\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepseek-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepseekApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setDeepseekApiKey(apiKey);\n\n // Cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving DeepSeek API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n const _pricingCache = new Map<string, { data: any; ts: number }>();\n const PRICING_CACHE_TTL_MS = 5 * 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n appState.broadcast('model-changed', defaultModel);\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-pricing', async () => {\n try {\n const cached = _pricingCache.get('pricing');\n if (cached && Date.now() - cached.ts < PRICING_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/pricing', {\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n _pricingCache.set('pricing', { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n const validateCurlProviderPayload = (provider: unknown): { ok: true } | { ok: false; error: string } => {\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider payload' };\n }\n\n if (!(provider as any).curlCommand.includes('{{TEXT}}')) {\n return { ok: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n if (\n 'responsePath' in provider &&\n typeof (provider as any).responsePath !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider responsePath' };\n }\n\n return { ok: true };\n };\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-custom-provider: invalid payload');\n return { success: false, error: validation.error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-curl-provider: invalid payload');\n return { success: false, error: validation.error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasDeepseekKey: hasKey(creds.deepseekApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n deepseekPreferredModel: creds.deepseekPreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasDeepseekKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n else if (provider === 'deepseek') key = cm.getDeepseekApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n else if (provider === 'deepseek') apiKey = creds.getDeepseekApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n } else if (provider === 'deepseek') {\n response = await axios.post(\n 'https://api.deepseek.com/chat/completions',\n {\n model: 'deepseek-v4-flash',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n sm.set('codexCliServiceTier', normalized.serviceTier);\n sm.set('codexCliModelReasoningEffort', normalized.modelReasoningEffort);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings), update runtime, and notify model UI surfaces\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n appState.broadcast('model-changed', modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n await appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n // UX2: in-app TCC repair button.\n //\n // Runs `tccutil reset Microphone <bundleId>` AND\n // `tccutil reset ScreenCapture <bundleId>` to clear stale macOS TCC entries\n // for Natively. This is the user-facing self-service recovery for the\n // dominant \"permissions appear granted in System Settings but capture is\n // silently zero-filled\" failure mode — which is caused by TCC binding the\n // grant to a binary's cdhash, and the cdhash changing on every rebuild\n // (ad-hoc-signed builds — see AUDIO_RELIABILITY_REPORT.md §3 A1).\n //\n // After tccutil reset, the user MUST force-quit and relaunch the app for\n // the next TCC prompt to appear cleanly. We return the prompt copy so the\n // renderer can show a \"Quit & relaunch\" CTA.\n //\n // Service-name capitalization MATTERS: Apple requires capital `Microphone`\n // and `ScreenCapture` — lowercase fails with \"Invalid Service Name.\" This\n // is the most common implementation bug.\n safeHandle('repair-tcc-permissions', async () => {\n if (process.platform !== 'darwin') {\n return { ok: false, error: 'TCC repair is macOS-only.' };\n }\n\n // Bundle ID resolution: prefer the live Electron app identifier (handles\n // signed packaged builds and dev-mode Electron alike). Falls back to the\n // package.json appId if app.getAppPath() inspection somehow fails.\n let bundleId: string;\n try {\n // app.isPackaged → packaged Info.plist CFBundleIdentifier\n // (== package.json build.appId for electron-builder)\n // !app.isPackaged → 'com.github.Electron' (the dev Electron binary's\n // bundle id; TCC entries land here in dev mode)\n bundleId = app.isPackaged ? 'com.electron.meeting-notes' : 'com.github.Electron';\n } catch {\n bundleId = 'com.electron.meeting-notes';\n }\n\n const { execFile } = require('node:child_process');\n const { promisify } = require('node:util');\n const execFileAsync = promisify(execFile);\n\n const services = ['Microphone', 'ScreenCapture']; // Capital letters REQUIRED.\n const results: Array<{ service: string; ok: boolean; output: string }> = [];\n\n for (const service of services) {\n try {\n // Absolute path — defense-in-depth against PATH shadowing. tccutil is\n // a SIP-protected stock macOS binary at /usr/bin/tccutil; using the\n // bare name would resolve via inherited PATH, which a user-modified\n // shell could in theory redirect.\n const { stdout, stderr } = await execFileAsync('/usr/bin/tccutil', ['reset', service, bundleId], {\n timeout: 5000,\n });\n results.push({ service, ok: true, output: (stdout || stderr || '').toString().trim() });\n console.log(`[IPC] tccutil reset ${service} ${bundleId}: OK`);\n } catch (err: any) {\n const msg = err?.stderr?.toString?.() || err?.message || String(err);\n results.push({ service, ok: false, output: msg.trim() });\n console.warn(`[IPC] tccutil reset ${service} ${bundleId} failed: ${msg}`);\n }\n }\n\n const anyOk = results.some((r) => r.ok);\n return {\n ok: anyOk,\n bundleId,\n results,\n promptRelaunch: anyOk,\n message: anyOk\n ? 'Permissions reset. Quit Natively completely (Cmd+Q) and reopen — macOS will ask you to grant Microphone and Screen Recording again. Approve both to restore audio capture.'\n : `Permission reset failed for ${bundleId}. ${results\n .filter((r) => !r.ok)\n .map((r) => `${r.service}: ${r.output}`)\n .join('; ')}`,\n };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl = parsed.protocol === 'https:';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // Date.now() alone collides when two queries fire in the same ms — the\n // second `set` would overwrite the first AbortController, the first\n // stream would become un-cancellable, and the `finally` `delete` would\n // evict the wrong entry. UUID guarantees uniqueness.\n // (Note: rag:cancel-query only matches `meeting-` and `global` prefixes,\n // so `live-` keys aren't cancellable through that path — pre-existing\n // behaviour, not regressed by this change.)\n const queryKey = `live-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // See live-${...} comment above for why Date.now() alone is unsafe.\n const queryKey = `global-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n if (!global && !meetingId) {\n return { success: false, error: 'meetingId is required' };\n }\n\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n const matchesQuery = global ? key.startsWith('global-') : key.startsWith(`${queryKey}-`);\n if (matchesQuery) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n // Allowlist of file paths the user explicitly selected via profile:select-file.\n // Without this, a compromised renderer could pass arbitrary filesystem paths\n // (e.g. /etc/passwd, ~/.ssh/id_rsa) to the upload handlers and exfiltrate\n // their contents through the knowledge index. Entries expire after 60s.\n const PROFILE_SELECTED_PATH_TTL_MS = 60_000;\n const profileSelectedPaths = new Map<string, number>();\n const normalizeProfilePath = (p: string): string => path.resolve(p);\n const sweepExpiredProfilePaths = (now: number): void => {\n for (const [key, expiresAt] of profileSelectedPaths) {\n if (now > expiresAt) profileSelectedPaths.delete(key);\n }\n };\n const registerSelectedProfilePath = (filePath: string): void => {\n const now = Date.now();\n sweepExpiredProfilePaths(now);\n profileSelectedPaths.set(normalizeProfilePath(filePath), now + PROFILE_SELECTED_PATH_TTL_MS);\n };\n const consumeSelectedProfilePath = (filePath: unknown): string | null => {\n if (typeof filePath !== 'string' || filePath.length === 0) return null;\n const key = normalizeProfilePath(filePath);\n const expiresAt = profileSelectedPaths.get(key);\n if (!expiresAt) return null;\n if (Date.now() > expiresAt) {\n profileSelectedPaths.delete(key);\n return null;\n }\n profileSelectedPaths.delete(key);\n return key;\n };\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-resume rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the resume file.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n const selected = result.filePaths[0];\n registerSelectedProfilePath(selected);\n return { success: true, filePath: selected };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-jd rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the JD file.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${crypto.randomUUID()}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const helper = appState.getWindowHelper();\n const sent = new Set<number>();\n for (const w of [helper.getLauncherWindow(), helper.getOverlayWindow()]) {\n if (!w || w.isDestroyed() || sent.has(w.id)) continue;\n sent.add(w.id);\n try {\n w.webContents.send('global-shortcut', { action: cmd.action });\n } catch {\n // Window is tearing down; keep delivering to any other valid surface.\n }\n }\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "a786910e33767a00116a519a3009ec14db812123": {"t": 1785754406.8423476, "n": 196562, "h": "ddddfcae307be24e", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n const safeOn = (\n channel: string,\n listener: (event: any, ...args: any[]) => void,\n ) => {\n ipcMain.removeAllListeners(channel);\n ipcMain.on(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n let _chatStreamId = 0;\n // Keep IDs globally unique for phone/desktop message correlation; supersession is per sender.\n const _chatStreamsBySender = new Map<number, { streamId: number; controller: AbortController }>();\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n let myController: AbortController | null = null;\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n const senderId = event.sender.id;\n const myStreamId = ++_chatStreamId;\n const priorStream = _chatStreamsBySender.get(senderId);\n if (priorStream) {\n try { priorStream.controller.abort(); } catch { /* noop */ }\n }\n myController = new AbortController();\n _chatStreamsBySender.set(senderId, { streamId: myStreamId, controller: myController });\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded for sender ${senderId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing. Pass the abort signal as\n // the trailing arg so the generator stops yielding when this stream\n // is superseded or explicitly cancelled via gemini-chat-stream-stop.\n // The signature accepts a final optional `abortSignal?: AbortSignal`\n // that streamChat extracts from its variadic args.\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n false, // skipModeInjection\n [], // extraDataScopes\n myController.signal,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded for sender ${senderId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n } finally {\n if (myController) {\n const current = _chatStreamsBySender.get(event.sender.id);\n if (current?.controller === myController) {\n _chatStreamsBySender.delete(event.sender.id);\n }\n }\n }\n },\n );\n\n // Renderer-driven cancellation for the sender's active chat stream.\n safeOn('gemini-chat-stream-stop', (event) => {\n const senderId = event.sender.id;\n const stream = _chatStreamsBySender.get(senderId);\n if (stream) {\n try { stream.controller.abort(); } catch { /* noop */ }\n _chatStreamsBySender.delete(senderId);\n }\n });\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Whether this build can perform a real in-place auto-install + relaunch\n // (signed macOS build, or any packaged Windows/Linux build). The renderer\n // uses this to choose the in-app update flow vs. the manual download fallback.\n safeHandle('get-can-auto-update', async () => {\n try {\n return { canAutoUpdate: appState.canAutoUpdate() };\n } catch (err: any) {\n console.error('[IPC] get-can-auto-update failed:', err);\n return { canAutoUpdate: false };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n // Return the AUTHORITATIVE final state so the renderer can reconcile / roll\n // back its optimistic toggle instead of assuming success (RC-2).\n return { success: true, state: appState.getUndetectable() };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n // Authoritative final state for renderer reconciliation (RC-2).\n return { success: true, enabled: appState.getOverlayMousePassthrough() };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled. Hardened against log injection\n // (CWE-117) and rotation thrash by validating types, capping length, stripping\n // control characters, and rate-limiting per sender.\n const FORWARD_LOG_MAX_LEN = 4 * 1024;\n const FORWARD_LOG_RATE_REFILL_MS = 1_000;\n const FORWARD_LOG_RATE_BUCKET = 200;\n const _forwardLogBuckets = new Map<number, { tokens: number; lastRefill: number }>();\n safeOn('forward-log-to-file', (event, level: unknown, msg: unknown) => {\n if (!appState.getVerboseLogging()) return;\n if (typeof level !== 'string' || typeof msg !== 'string') return;\n\n const senderId = event.sender?.id ?? -1;\n const now = Date.now();\n let bucket = _forwardLogBuckets.get(senderId);\n if (!bucket) {\n bucket = { tokens: FORWARD_LOG_RATE_BUCKET, lastRefill: now };\n _forwardLogBuckets.set(senderId, bucket);\n // Reap the bucket when the renderer goes away so the Map cannot grow\n // unbounded across renderer reloads / hidden-window churn.\n try {\n event.sender?.once?.('destroyed', () => {\n _forwardLogBuckets.delete(senderId);\n });\n } catch { /* noop */ }\n } else {\n const elapsed = now - bucket.lastRefill;\n if (elapsed > 0) {\n const refill = Math.floor((elapsed * FORWARD_LOG_RATE_BUCKET) / FORWARD_LOG_RATE_REFILL_MS);\n if (refill > 0) {\n bucket.tokens = Math.min(FORWARD_LOG_RATE_BUCKET, bucket.tokens + refill);\n bucket.lastRefill += Math.floor((refill * FORWARD_LOG_RATE_REFILL_MS) / FORWARD_LOG_RATE_BUCKET);\n }\n }\n }\n if (bucket.tokens <= 0) return;\n bucket.tokens -= 1;\n\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n const sanitized = msg\n .replace(/[\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]/g, ' ')\n .slice(0, FORWARD_LOG_MAX_LEN);\n console.log(`${tag}[${senderId}] ${sanitized}`);\n });\n\n // Meeting interface theme cross-window broadcast. The settings window writes\n // localStorage + sends this IPC; main re-broadcasts to every renderer so the\n // overlay window's React state updates without depending on the same-origin\n // `storage` event (which does not cross BrowserWindow boundaries in Electron).\n // Without this, switching the meeting interface theme while the overlay is\n // hidden leaves it with stale CSS on the next meeting start — manifest as a\n // half-painted UI that requires force-quit.\n // Allowlist must mirror MeetingInterfaceTheme in src/lib/meetingInterfaceTheme.ts.\n // Any string that reaches a renderer via interface-theme:changed ends up in\n // a `data-interface-theme={value}` DOM attribute on the overlay's wrapper\n // div (NativelyInterface.tsx). Without an allowlist, a compromised or buggy\n // renderer could broadcast an arbitrary string — at best CSS selector\n // mismatch (overlay falls back to default), at worst an attribute-injection\n // vector if any consumer ever switched from `setAttribute` to template\n // literals. Hardening the trust boundary at the broadcast point is cheap.\n const VALID_INTERFACE_THEMES = new Set(['default', 'liquid-glass', 'modern']);\n safeOn('interface-theme:set', (_event, theme: string) => {\n if (typeof theme !== 'string' || !VALID_INTERFACE_THEMES.has(theme)) {\n // Truncate + strip control chars before logging — a 64-char payload can\n // still embed \\n/\\r to forge log lines if a future log shipper parses\n // newline-delimited records.\n const safe = typeof theme === 'string'\n ? theme.slice(0, 64).replace(/[\\r\\n\\x00-\\x1f]/g, '?')\n : typeof theme;\n console.warn(`[interface-theme:set] Rejected unknown theme: ${safe}`);\n return;\n }\n BrowserWindow.getAllWindows().forEach((win) => {\n if (win.isDestroyed()) return;\n try {\n win.webContents.send('interface-theme:changed', theme);\n } catch {\n // Renderer may be tearing down between isDestroyed() and send.\n }\n });\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepseek-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepseekApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setDeepseekApiKey(apiKey);\n\n // Cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving DeepSeek API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n const _pricingCache = new Map<string, { data: any; ts: number }>();\n const PRICING_CACHE_TTL_MS = 5 * 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n appState.sendModelChanged(defaultModel);\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-pricing', async () => {\n try {\n const cached = _pricingCache.get('pricing');\n if (cached && Date.now() - cached.ts < PRICING_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/pricing', {\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n _pricingCache.set('pricing', { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n const validateCurlProviderPayload = (provider: unknown): { ok: true } | { ok: false; error: string } => {\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider payload' };\n }\n\n if (!(provider as any).curlCommand.includes('{{TEXT}}')) {\n return { ok: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n if (\n 'responsePath' in provider &&\n typeof (provider as any).responsePath !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider responsePath' };\n }\n\n return { ok: true };\n };\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-custom-provider: invalid payload');\n return { success: false, error: validation.error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-curl-provider: invalid payload');\n return { success: false, error: validation.error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasDeepseekKey: hasKey(creds.deepseekApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n deepseekPreferredModel: creds.deepseekPreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasDeepseekKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n else if (provider === 'deepseek') key = cm.getDeepseekApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n else if (provider === 'deepseek') apiKey = creds.getDeepseekApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n } else if (provider === 'deepseek') {\n response = await axios.post(\n 'https://api.deepseek.com/chat/completions',\n {\n model: 'deepseek-v4-flash',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n sm.set('codexCliServiceTier', normalized.serviceTier);\n sm.set('codexCliModelReasoningEffort', normalized.modelReasoningEffort);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n appState.sendModelChanged(modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings), update runtime, and notify model UI surfaces\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n appState.sendModelChanged(modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n await appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n // UX2: in-app TCC repair button.\n //\n // Runs `tccutil reset Microphone <bundleId>` AND\n // `tccutil reset ScreenCapture <bundleId>` to clear stale macOS TCC entries\n // for Natively. This is the user-facing self-service recovery for the\n // dominant \"permissions appear granted in System Settings but capture is\n // silently zero-filled\" failure mode — which is caused by TCC binding the\n // grant to a binary's cdhash, and the cdhash changing on every rebuild\n // (ad-hoc-signed builds — see AUDIO_RELIABILITY_REPORT.md §3 A1).\n //\n // After tccutil reset, the user MUST force-quit and relaunch the app for\n // the next TCC prompt to appear cleanly. We return the prompt copy so the\n // renderer can show a \"Quit & relaunch\" CTA.\n //\n // Service-name capitalization MATTERS: Apple requires capital `Microphone`\n // and `ScreenCapture` — lowercase fails with \"Invalid Service Name.\" This\n // is the most common implementation bug.\n safeHandle('repair-tcc-permissions', async () => {\n if (process.platform !== 'darwin') {\n return { ok: false, error: 'TCC repair is macOS-only.' };\n }\n\n // Bundle ID resolution: prefer the live Electron app identifier (handles\n // signed packaged builds and dev-mode Electron alike). Falls back to the\n // package.json appId if app.getAppPath() inspection somehow fails.\n let bundleId: string;\n try {\n // app.isPackaged → packaged Info.plist CFBundleIdentifier\n // (== package.json build.appId for electron-builder)\n // !app.isPackaged → 'com.github.Electron' (the dev Electron binary's\n // bundle id; TCC entries land here in dev mode)\n bundleId = app.isPackaged ? 'com.electron.meeting-notes' : 'com.github.Electron';\n } catch {\n bundleId = 'com.electron.meeting-notes';\n }\n\n const { execFile } = require('node:child_process');\n const { promisify } = require('node:util');\n const execFileAsync = promisify(execFile);\n\n const services = ['Microphone', 'ScreenCapture']; // Capital letters REQUIRED.\n const results: Array<{ service: string; ok: boolean; output: string }> = [];\n\n for (const service of services) {\n try {\n // Absolute path — defense-in-depth against PATH shadowing. tccutil is\n // a SIP-protected stock macOS binary at /usr/bin/tccutil; using the\n // bare name would resolve via inherited PATH, which a user-modified\n // shell could in theory redirect.\n const { stdout, stderr } = await execFileAsync('/usr/bin/tccutil', ['reset', service, bundleId], {\n timeout: 5000,\n });\n results.push({ service, ok: true, output: (stdout || stderr || '').toString().trim() });\n console.log(`[IPC] tccutil reset ${service} ${bundleId}: OK`);\n } catch (err: any) {\n const msg = err?.stderr?.toString?.() || err?.message || String(err);\n results.push({ service, ok: false, output: msg.trim() });\n console.warn(`[IPC] tccutil reset ${service} ${bundleId} failed: ${msg}`);\n }\n }\n\n const anyOk = results.some((r) => r.ok);\n return {\n ok: anyOk,\n bundleId,\n results,\n promptRelaunch: anyOk,\n message: anyOk\n ? 'Permissions reset. Quit Natively completely (Cmd+Q) and reopen — macOS will ask you to grant Microphone and Screen Recording again. Approve both to restore audio capture.'\n : `Permission reset failed for ${bundleId}. ${results\n .filter((r) => !r.ok)\n .map((r) => `${r.service}: ${r.output}`)\n .join('; ')}`,\n };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl =\n parsed.protocol === 'https:' &&\n parsed.hostname === 'mail.google.com' &&\n parsed.pathname === '/mail/';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // Date.now() alone collides when two queries fire in the same ms — the\n // second `set` would overwrite the first AbortController, the first\n // stream would become un-cancellable, and the `finally` `delete` would\n // evict the wrong entry. UUID guarantees uniqueness.\n // (Note: rag:cancel-query only matches `meeting-` and `global` prefixes,\n // so `live-` keys aren't cancellable through that path — pre-existing\n // behaviour, not regressed by this change.)\n const queryKey = `live-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // See live-${...} comment above for why Date.now() alone is unsafe.\n const queryKey = `global-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n if (!global && !meetingId) {\n return { success: false, error: 'meetingId is required' };\n }\n\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n const matchesQuery = global ? key.startsWith('global-') : key.startsWith(`${queryKey}-`);\n if (matchesQuery) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n // Allowlist of file paths the user explicitly selected via profile:select-file.\n // Without this, a compromised renderer could pass arbitrary filesystem paths\n // (e.g. /etc/passwd, ~/.ssh/id_rsa) to the upload handlers and exfiltrate\n // their contents through the knowledge index. Entries expire after 60s.\n const PROFILE_SELECTED_PATH_TTL_MS = 60_000;\n const profileSelectedPaths = new Map<string, number>();\n const normalizeProfilePath = (p: string): string => path.resolve(p);\n const sweepExpiredProfilePaths = (now: number): void => {\n for (const [key, expiresAt] of profileSelectedPaths) {\n if (now > expiresAt) profileSelectedPaths.delete(key);\n }\n };\n const registerSelectedProfilePath = (filePath: string): void => {\n const now = Date.now();\n sweepExpiredProfilePaths(now);\n profileSelectedPaths.set(normalizeProfilePath(filePath), now + PROFILE_SELECTED_PATH_TTL_MS);\n };\n const consumeSelectedProfilePath = (filePath: unknown): string | null => {\n if (typeof filePath !== 'string' || filePath.length === 0) return null;\n const key = normalizeProfilePath(filePath);\n const expiresAt = profileSelectedPaths.get(key);\n if (!expiresAt) return null;\n if (Date.now() > expiresAt) {\n profileSelectedPaths.delete(key);\n return null;\n }\n profileSelectedPaths.delete(key);\n return key;\n };\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-resume rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the resume file.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n const selected = result.filePaths[0];\n registerSelectedProfilePath(selected);\n return { success: true, filePath: selected };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-jd rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the JD file.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${crypto.randomUUID()}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const helper = appState.getWindowHelper();\n const sent = new Set<number>();\n for (const w of [helper.getLauncherWindow(), helper.getOverlayWindow()]) {\n if (!w || w.isDestroyed() || sent.has(w.id)) continue;\n sent.add(w.id);\n try {\n w.webContents.send('global-shortcut', { action: cmd.action });\n } catch {\n // Window is tearing down; keep delivering to any other valid surface.\n }\n }\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "18b139b123c12f742136e5d1b7def980530d9d08": {"t": 1785754409.6161542, "n": 192046, "h": "e90ed7832f1f48c2", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n const safeOn = (\n channel: string,\n listener: (event: any, ...args: any[]) => void,\n ) => {\n ipcMain.removeAllListeners(channel);\n ipcMain.on(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n let _chatStreamId = 0;\n // Keep IDs globally unique for phone/desktop message correlation; supersession is per sender.\n const _chatStreamsBySender = new Map<number, { streamId: number; controller: AbortController }>();\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n let myController: AbortController | null = null;\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n const senderId = event.sender.id;\n const myStreamId = ++_chatStreamId;\n const priorStream = _chatStreamsBySender.get(senderId);\n if (priorStream) {\n try { priorStream.controller.abort(); } catch { /* noop */ }\n }\n myController = new AbortController();\n _chatStreamsBySender.set(senderId, { streamId: myStreamId, controller: myController });\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded for sender ${senderId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing. Pass the abort signal as\n // the trailing arg so the generator stops yielding when this stream\n // is superseded or explicitly cancelled via gemini-chat-stream-stop.\n // The signature accepts a final optional `abortSignal?: AbortSignal`\n // that streamChat extracts from its variadic args.\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n false, // skipModeInjection\n [], // extraDataScopes\n myController.signal,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamsBySender.get(senderId)?.streamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded for sender ${senderId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamsBySender.get(senderId)?.streamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n } finally {\n if (myController) {\n const current = _chatStreamsBySender.get(event.sender.id);\n if (current?.controller === myController) {\n _chatStreamsBySender.delete(event.sender.id);\n }\n }\n }\n },\n );\n\n // Renderer-driven cancellation for the sender's active chat stream.\n safeOn('gemini-chat-stream-stop', (event) => {\n const senderId = event.sender.id;\n const stream = _chatStreamsBySender.get(senderId);\n if (stream) {\n try { stream.controller.abort(); } catch { /* noop */ }\n _chatStreamsBySender.delete(senderId);\n }\n });\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n return { success: true };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n return { success: true };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled. Hardened against log injection\n // (CWE-117) and rotation thrash by validating types, capping length, stripping\n // control characters, and rate-limiting per sender.\n const FORWARD_LOG_MAX_LEN = 4 * 1024;\n const FORWARD_LOG_RATE_REFILL_MS = 1_000;\n const FORWARD_LOG_RATE_BUCKET = 200;\n const _forwardLogBuckets = new Map<number, { tokens: number; lastRefill: number }>();\n safeOn('forward-log-to-file', (event, level: unknown, msg: unknown) => {\n if (!appState.getVerboseLogging()) return;\n if (typeof level !== 'string' || typeof msg !== 'string') return;\n\n const senderId = event.sender?.id ?? -1;\n const now = Date.now();\n let bucket = _forwardLogBuckets.get(senderId);\n if (!bucket) {\n bucket = { tokens: FORWARD_LOG_RATE_BUCKET, lastRefill: now };\n _forwardLogBuckets.set(senderId, bucket);\n // Reap the bucket when the renderer goes away so the Map cannot grow\n // unbounded across renderer reloads / hidden-window churn.\n try {\n event.sender?.once?.('destroyed', () => {\n _forwardLogBuckets.delete(senderId);\n });\n } catch { /* noop */ }\n } else {\n const elapsed = now - bucket.lastRefill;\n if (elapsed > 0) {\n const refill = Math.floor((elapsed * FORWARD_LOG_RATE_BUCKET) / FORWARD_LOG_RATE_REFILL_MS);\n if (refill > 0) {\n bucket.tokens = Math.min(FORWARD_LOG_RATE_BUCKET, bucket.tokens + refill);\n bucket.lastRefill += Math.floor((refill * FORWARD_LOG_RATE_REFILL_MS) / FORWARD_LOG_RATE_BUCKET);\n }\n }\n }\n if (bucket.tokens <= 0) return;\n bucket.tokens -= 1;\n\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n const sanitized = msg\n .replace(/[\\r\\n\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]/g, ' ')\n .slice(0, FORWARD_LOG_MAX_LEN);\n console.log(`${tag}[${senderId}] ${sanitized}`);\n });\n\n // Meeting interface theme cross-window broadcast. The settings window writes\n // localStorage + sends this IPC; main re-broadcasts to every renderer so the\n // overlay window's React state updates without depending on the same-origin\n // `storage` event (which does not cross BrowserWindow boundaries in Electron).\n // Without this, switching the meeting interface theme while the overlay is\n // hidden leaves it with stale CSS on the next meeting start — manifest as a\n // half-painted UI that requires force-quit.\n // Allowlist must mirror MeetingInterfaceTheme in src/lib/meetingInterfaceTheme.ts.\n // Any string that reaches a renderer via interface-theme:changed ends up in\n // a `data-interface-theme={value}` DOM attribute on the overlay's wrapper\n // div (NativelyInterface.tsx). Without an allowlist, a compromised or buggy\n // renderer could broadcast an arbitrary string — at best CSS selector\n // mismatch (overlay falls back to default), at worst an attribute-injection\n // vector if any consumer ever switched from `setAttribute` to template\n // literals. Hardening the trust boundary at the broadcast point is cheap.\n const VALID_INTERFACE_THEMES = new Set(['default', 'liquid-glass', 'modern']);\n safeOn('interface-theme:set', (_event, theme: string) => {\n if (typeof theme !== 'string' || !VALID_INTERFACE_THEMES.has(theme)) {\n // Truncate + strip control chars before logging — a 64-char payload can\n // still embed \\n/\\r to forge log lines if a future log shipper parses\n // newline-delimited records.\n const safe = typeof theme === 'string'\n ? theme.slice(0, 64).replace(/[\\r\\n\\x00-\\x1f]/g, '?')\n : typeof theme;\n console.warn(`[interface-theme:set] Rejected unknown theme: ${safe}`);\n return;\n }\n BrowserWindow.getAllWindows().forEach((win) => {\n if (win.isDestroyed()) return;\n try {\n win.webContents.send('interface-theme:changed', theme);\n } catch {\n // Renderer may be tearing down between isDestroyed() and send.\n }\n });\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepseek-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepseekApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setDeepseekApiKey(apiKey);\n\n // Cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving DeepSeek API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n const _pricingCache = new Map<string, { data: any; ts: number }>();\n const PRICING_CACHE_TTL_MS = 5 * 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n appState.sendModelChanged(defaultModel);\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-pricing', async () => {\n try {\n const cached = _pricingCache.get('pricing');\n if (cached && Date.now() - cached.ts < PRICING_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/pricing', {\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n _pricingCache.set('pricing', { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n const validateCurlProviderPayload = (provider: unknown): { ok: true } | { ok: false; error: string } => {\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider payload' };\n }\n\n if (!(provider as any).curlCommand.includes('{{TEXT}}')) {\n return { ok: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n if (\n 'responsePath' in provider &&\n typeof (provider as any).responsePath !== 'string'\n ) {\n return { ok: false, error: 'Invalid provider responsePath' };\n }\n\n return { ok: true };\n };\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-custom-provider: invalid payload');\n return { success: false, error: validation.error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: unknown) => {\n try {\n const validation = validateCurlProviderPayload(provider);\n if (!validation.ok) {\n console.error('[IPC] save-curl-provider: invalid payload');\n return { success: false, error: validation.error };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider as any);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasDeepseekKey: hasKey(creds.deepseekApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n deepseekPreferredModel: creds.deepseekPreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasDeepseekKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n else if (provider === 'deepseek') key = cm.getDeepseekApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude' | 'deepseek', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n else if (provider === 'deepseek') apiKey = creds.getDeepseekApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n } else if (provider === 'deepseek') {\n response = await axios.post(\n 'https://api.deepseek.com/chat/completions',\n {\n model: 'deepseek-v4-flash',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n appState.sendModelChanged(modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings), update runtime, and notify model UI surfaces\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n appState.sendModelChanged(modelId);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number; activate?: boolean }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y, { activate: coords.activate });\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n await appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl =\n parsed.protocol === 'https:' &&\n parsed.hostname === 'mail.google.com' &&\n parsed.pathname === '/mail/';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // Date.now() alone collides when two queries fire in the same ms — the\n // second `set` would overwrite the first AbortController, the first\n // stream would become un-cancellable, and the `finally` `delete` would\n // evict the wrong entry. UUID guarantees uniqueness.\n // (Note: rag:cancel-query only matches `meeting-` and `global` prefixes,\n // so `live-` keys aren't cancellable through that path — pre-existing\n // behaviour, not regressed by this change.)\n const queryKey = `live-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // See live-${...} comment above for why Date.now() alone is unsafe.\n const queryKey = `global-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n if (!global && !meetingId) {\n return { success: false, error: 'meetingId is required' };\n }\n\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n const matchesQuery = global ? key.startsWith('global-') : key.startsWith(`${queryKey}-`);\n if (matchesQuery) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n // Allowlist of file paths the user explicitly selected via profile:select-file.\n // Without this, a compromised renderer could pass arbitrary filesystem paths\n // (e.g. /etc/passwd, ~/.ssh/id_rsa) to the upload handlers and exfiltrate\n // their contents through the knowledge index. Entries expire after 60s.\n const PROFILE_SELECTED_PATH_TTL_MS = 60_000;\n const profileSelectedPaths = new Map<string, number>();\n const normalizeProfilePath = (p: string): string => path.resolve(p);\n const sweepExpiredProfilePaths = (now: number): void => {\n for (const [key, expiresAt] of profileSelectedPaths) {\n if (now > expiresAt) profileSelectedPaths.delete(key);\n }\n };\n const registerSelectedProfilePath = (filePath: string): void => {\n const now = Date.now();\n sweepExpiredProfilePaths(now);\n profileSelectedPaths.set(normalizeProfilePath(filePath), now + PROFILE_SELECTED_PATH_TTL_MS);\n };\n const consumeSelectedProfilePath = (filePath: unknown): string | null => {\n if (typeof filePath !== 'string' || filePath.length === 0) return null;\n const key = normalizeProfilePath(filePath);\n const expiresAt = profileSelectedPaths.get(key);\n if (!expiresAt) return null;\n if (Date.now() > expiresAt) {\n profileSelectedPaths.delete(key);\n return null;\n }\n profileSelectedPaths.delete(key);\n return key;\n };\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-resume rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the resume file.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n const selected = result.filePaths[0];\n registerSelectedProfilePath(selected);\n return { success: true, filePath: selected };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const resolvedPath = consumeSelectedProfilePath(filePath);\n if (!resolvedPath) {\n console.warn('[IPC] profile:upload-jd rejected: path was not produced by profile:select-file or has expired.');\n return { success: false, error: 'Please re-select the JD file.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${resolvedPath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(resolvedPath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${crypto.randomUUID()}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const helper = appState.getWindowHelper();\n const sent = new Set<number>();\n for (const w of [helper.getLauncherWindow(), helper.getOverlayWindow()]) {\n if (!w || w.isDestroyed() || sent.has(w.id)) continue;\n sent.add(w.id);\n try {\n w.webContents.send('global-shortcut', { action: cmd.action });\n } catch {\n // Window is tearing down; keep delivering to any other valid surface.\n }\n }\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "babb4a1e85fd2e43cd527b552f6f1cd90af77407": {"t": 1785754434.8442447, "n": 181945, "h": "6a392066a1d36350", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n }\n },\n );\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n return { success: true };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n return { success: true };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on('forward-log-to-file', (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return {\n success: false,\n error: 'curlCommand must contain {{TEXT}} placeholder for the prompt',\n };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl =\n parsed.protocol === 'https:' &&\n parsed.hostname === 'mail.google.com' &&\n parsed.pathname === '/mail/';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // Date.now() alone collides when two queries fire in the same ms — the\n // second `set` would overwrite the first AbortController, the first\n // stream would become un-cancellable, and the `finally` `delete` would\n // evict the wrong entry. UUID guarantees uniqueness.\n // (Note: rag:cancel-query only matches `meeting-` and `global` prefixes,\n // so `live-` keys aren't cancellable through that path — pre-existing\n // behaviour, not regressed by this change.)\n const queryKey = `live-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n // See live-${...} comment above for why Date.now() alone is unsafe.\n const queryKey = `global-${crypto.randomUUID()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${crypto.randomUUID()}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const allWindows = BrowserWindow.getAllWindows();\n allWindows.forEach((w) => {\n if (!w.isDestroyed()) w.webContents.send('global-shortcut', { action: cmd.action });\n });\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "b2b7b53e83935d9bd9d9522e47dcea72288c8e8d": {"t": 1785754437.346792, "n": 181402, "h": "bf83e1c0dc78bbe2", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\nimport { SkillsManager } from './services/SkillsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n }\n },\n );\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n return { success: true };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n return { success: true };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on('forward-log-to-file', (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return {\n success: false,\n error: 'curlCommand must contain {{TEXT}} placeholder for the prompt',\n };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl =\n parsed.protocol === 'https:' &&\n parsed.hostname === 'mail.google.com' &&\n parsed.pathname === '/mail/';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n const localVisionAvailable = credentials.anyLocalVisionProviderConfigured?.() ?? false;\n if (providerScopes.screenshots === false) {\n console.warn(\n localVisionAvailable\n ? '[ScopeFallback] screenshots denied for cloud; routing to Ollama'\n : '[ScopeFallback] screenshots denied; Ollama unavailable, omitting from context',\n );\n }\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('skills:list', () => {\n try {\n return SkillsManager.getInstance().listSkills();\n } catch (e: any) {\n console.warn('[IPC] skills:list error:', e?.message || e);\n return [];\n }\n });\n\n safeHandle('skills:open-folder', async () => {\n try {\n return await SkillsManager.getInstance().openSkillsFolder();\n } catch (e: any) {\n console.warn('[IPC] skills:open-folder error:', e?.message || e);\n return { success: false, path: '', error: e?.message || 'failed to open skills folder' };\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const allWindows = BrowserWindow.getAllWindows();\n allWindows.forEach((w) => {\n if (!w.isDestroyed()) w.webContents.send('global-shortcut', { action: cmd.action });\n });\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "2bedf59b37ddd86310a6089b879b5dc82bef4eef": {"t": 1785754439.8400674, "n": 180415, "h": "a16b91d6fb4b684a", "c": "// ipcHandlers.ts\n\nimport * as crypto from 'crypto';\nimport { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } from 'electron';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { AudioDevices } from './audio/AudioDevices';\nimport { DatabaseManager } from './db/DatabaseManager'; // Import Database Manager\nimport { AppState } from './main';\nimport { CodexCliService } from './services/CodexCliService';\nimport { PhoneMirrorService } from './services/PhoneMirrorService';\nimport { SettingsManager } from './services/SettingsManager';\n\nimport { TRIAL_SENTINEL_KEY } from './config/constants';\nimport { AI_RESPONSE_LANGUAGES, RECOGNITION_LANGUAGES } from './config/languages';\nimport { CHAT_MODE_PROMPT } from './llm/prompts';\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (\n channel: string,\n listener: (event: any, ...args: any[]) => Promise<any> | any,\n ) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch {\n /* premium module not available */\n }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) {\n /* non-fatal */\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle('test-release-fetch', async () => {\n try {\n console.log('[IPC] Manual Test Fetch triggered (forcing refresh)...');\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log('[IPC] Notes fetched for:', notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes,\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send('update-available', info);\n return { success: true };\n }\n return { success: false, error: 'No notes returned' };\n } catch (err: any) {\n console.error('[IPC] test-release-fetch failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('license:activate', async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle('license:check-premium', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle('license:get-details', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle('license:check-premium-async', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle('license:deactivate', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) {\n /* ignore */\n }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch {\n /* LicenseManager not available */\n }\n return { success: true };\n });\n safeHandle('license:get-hardware-id', async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle('get-recognition-languages', async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle('get-ai-response-languages', async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle('set-ai-response-language', async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn(\n '[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.',\n );\n }\n return { success: true };\n });\n\n safeHandle('get-stt-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle('get-ai-response-language', async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n 'update-content-dimensions',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n\n const senderWebContents = event.sender;\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow();\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n\n if (\n settingsWin &&\n !settingsWin.isDestroyed() &&\n settingsWin.webContents.id === senderWebContents.id\n ) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height);\n } else if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height);\n } else if (\n launcherWin &&\n !launcherWin.isDestroyed() &&\n launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(\n `[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`,\n );\n }\n },\n );\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n 'update-content-dimensions-centered',\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return;\n const senderWebContents = event.sender;\n const overlayWin = appState.getWindowHelper().getOverlayWindow();\n if (\n overlayWin &&\n !overlayWin.isDestroyed() &&\n overlayWin.webContents.id === senderWebContents.id\n ) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height);\n }\n },\n );\n\n safeHandle('set-window-mode', async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n });\n\n safeHandle('delete-screenshot', async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n });\n\n safeHandle('take-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error;\n }\n });\n\n safeHandle('take-selective-screenshot', async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot();\n const preview = await appState.getImagePreview(screenshotPath);\n return { path: screenshotPath, preview };\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === 'Selection cancelled') {\n return { cancelled: true };\n }\n throw error;\n }\n });\n\n safeHandle('get-screenshots', async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = [];\n if (appState.getView() === 'queue') {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path),\n })),\n );\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews;\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error;\n }\n });\n\n safeHandle('toggle-window', async () => {\n appState.toggleMainWindow();\n });\n\n safeHandle('show-window', async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive);\n });\n\n safeHandle('hide-window', async () => {\n appState.hideMainWindow();\n });\n\n safeHandle('show-overlay', async () => {\n appState.getWindowHelper().showOverlay();\n });\n\n safeHandle('hide-overlay', async () => {\n appState.getWindowHelper().hideOverlay();\n });\n\n safeHandle('get-meeting-active', async () => {\n return appState.getIsMeetingActive();\n });\n\n safeHandle('reset-queues', async () => {\n try {\n appState.clearQueues();\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message };\n }\n });\n\n // Donation IPC Handlers\n safeHandle('get-donation-status', async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows,\n };\n });\n\n safeHandle('mark-donation-toast-shown', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle('set-donation-complete', async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle('generate-suggestion', async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper\n .getLLMHelper()\n .generateSuggestion(context, lastQuestion);\n return { suggestion };\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error;\n }\n });\n\n safeHandle('finalize-mic-stt', async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle('analyze-image-file', async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved]);\n return result;\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\n 'gemini-chat',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean },\n ) => {\n try {\n const result = await appState.processingHelper\n .getLLMHelper()\n .chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn('[IPC] Empty response from LLM, not updating IntelligenceManager');\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, {\n length: intelligenceManager.getLastAssistantMessage()?.length ?? 0,\n });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n },\n );\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE =\n /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE =\n /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\n 'gemini-chat-stream',\n async (\n event,\n message: string,\n imagePaths?: string[],\n context?: string,\n options?: { skipSystemPrompt?: boolean; ignoreKnowledgeMode?: boolean },\n ) => {\n try {\n console.log('[IPC] gemini-chat-stream started using LLMHelper.streamChat');\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? 'I was developed by Evin John.'\n : IDENTITY_PROBE_RE.test(message)\n ? \"I'm Natively, an AI assistant.\"\n : null;\n if (identityHit) {\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', identityHit);\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit);\n } catch (_) {\n /* noop */\n }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn('[IPC] Failed to capture pre-turn context:', ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript(\n {\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true,\n },\n true,\n );\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try {\n PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message);\n } catch (_) {\n /* noop */\n }\n\n let fullResponse = '';\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(\n `[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`,\n );\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt\n ? ''\n : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(\n message,\n imagePaths,\n context,\n systemPromptOverride,\n options?.ignoreKnowledgeMode,\n );\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return null;\n }\n event.sender.send('gemini-stream-token', token);\n try {\n PhoneMirrorService.getInstance().publishToken(String(myStreamId), token);\n } catch (_) {\n /* noop */\n }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send('gemini-stream-done');\n try {\n PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse);\n } catch (_) {\n /* noop */\n }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n } catch (streamError: any) {\n console.error('[IPC] Streaming error:', streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\n 'gemini-stream-error',\n streamError.message || 'Unknown streaming error',\n );\n try {\n PhoneMirrorService.getInstance().publishError(\n String(myStreamId),\n streamError?.message || 'Unknown streaming error',\n );\n } catch (_) {\n /* noop */\n }\n }\n }\n\n return null; // Return null as data is sent via events\n } catch (error: any) {\n console.error('[IPC] Error in gemini-chat-stream setup:', error);\n throw error;\n }\n },\n );\n\n safeHandle('quit-app', () => {\n app.quit();\n });\n\n safeHandle('quit-and-install-update', async () => {\n try {\n console.log('[IPC] Quit and install update requested');\n await appState.quitAndInstallUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('delete-meeting', async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle('check-for-updates', async () => {\n try {\n console.log('[IPC] Manual update check requested');\n await appState.checkForUpdates();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle('download-update', async () => {\n try {\n console.log('[IPC] Download update requested');\n appState.downloadUpdate();\n return { success: true };\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err);\n return { success: false, error: err.message };\n }\n });\n\n // Window movement handlers\n safeHandle('move-window-left', async () => {\n appState.moveWindowLeft();\n });\n\n safeHandle('move-window-right', async () => {\n appState.moveWindowRight();\n });\n\n safeHandle('move-window-up', async () => {\n appState.moveWindowUp();\n });\n\n safeHandle('move-window-down', async () => {\n appState.moveWindowDown();\n });\n\n safeHandle('center-and-show-window', async () => {\n appState.centerAndShowWindow();\n });\n\n // Window Controls\n safeHandle('window-minimize', async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle('window-maximize', async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle('window-close', async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle('window-is-maximized', async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle('toggle-settings-window', (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y);\n });\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle('settings:open-tab', (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n if (appState.getUndetectable()) {\n launcherWin.showInactive();\n } else {\n launcherWin.show();\n launcherWin.focus();\n }\n }\n });\n\n safeHandle('close-settings-window', () => {\n appState.settingsWindowHelper.closeWindow();\n });\n\n safeHandle('set-undetectable', async (_, state: boolean) => {\n appState.setUndetectable(state);\n return { success: true };\n });\n\n safeHandle('set-disguise', async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode);\n return { success: true };\n });\n\n safeHandle('get-undetectable', async () => {\n return appState.getUndetectable();\n });\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle('set-overlay-mouse-passthrough', async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled);\n return { success: true };\n });\n\n safeHandle('toggle-overlay-mouse-passthrough', async () => {\n const enabled = appState.toggleOverlayMousePassthrough();\n return { success: true, enabled };\n });\n\n safeHandle('get-overlay-mouse-passthrough', async () => {\n return appState.getOverlayMousePassthrough();\n });\n\n safeHandle('get-disguise', async () => {\n return appState.getDisguise();\n });\n\n safeHandle('set-open-at-login', async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe'), // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle('get-open-at-login', async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle('get-verbose-logging', async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle('set-verbose-logging', async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle('get-meeting-retention', async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle('set-meeting-retention', async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-provider-data-scopes', async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle('set-provider-data-scopes', async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set([\n 'transcript',\n 'screenshots',\n 'reference_files',\n 'profile_history',\n 'embeddings',\n 'post_call_summary',\n ]);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-screen-understanding-mode', async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\n 'set-screen-understanding-mode',\n async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n },\n );\n\n safeHandle('get-technical-interview-vision-first', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle('set-technical-interview-vision-first', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle('get-technical-interview-direct-vision', async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle('set-technical-interview-direct-vision', async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle('get-log-file-path', async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle('open-log-file', async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on('forward-log-to-file', (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag =\n level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle('get-arch', async () => {\n return process.arch;\n });\n\n safeHandle('get-os-version', async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor =\n darwinMajor >= 25 ? darwinMajor + 1 : darwinMajor >= 20 ? darwinMajor - 9 : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle('get-current-llm-config', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama(),\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle('get-available-ollama-models', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle('switch-to-ollama', async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('force-restart-ollama', async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error('Error force restarting Ollama:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error('[IPC restart-ollama] Failed to restart:', error);\n return false;\n }\n });\n\n safeHandle('ensure-ollama-running', async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle('switch-to-gemini', async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle('set-gemini-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Gemini API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-claude-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Claude API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle('set-natively-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(\n `[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`,\n );\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log(\n '[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.',\n );\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:',\n e?.message,\n );\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log(\n '[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.',\n );\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed())\n win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn(\n '[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:',\n e?.message,\n );\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Natively API key:', error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n safeHandle('get-natively-usage', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = (await res.json()) as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle('invalidate-natively-usage-cache', () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle('trial:start', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch {\n /* LicenseManager not available — fall back */\n }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = (await res.json()) as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle('trial:status', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = (await res.json().catch(() => ({}))) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle('trial:get-local', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle('trial:convert', async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle('trial:end-byok', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch {\n /* LicenseManager not available in this build */\n }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore */\n }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle('trial:wipe-profile-data', async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch {\n /* ignore — orchestrator may not be initialised */\n }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle('get-custom-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error('Error getting custom providers:', error);\n return [];\n }\n });\n\n safeHandle('save-custom-provider', async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' ||\n provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return {\n success: false,\n error: 'curlCommand must contain {{TEXT}} placeholder for the prompt',\n };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-custom-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-custom-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])].find(\n (p: any) => p.id === providerId,\n );\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to custom provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // cURL Provider Handlers\n safeHandle('get-curl-providers', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error('Error getting curl providers:', error);\n return [];\n }\n });\n\n safeHandle('save-curl-provider', async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('delete-curl-provider', async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error('Error deleting curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('switch-to-curl-provider', async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance()\n .getCurlProviders()\n .find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error('Provider not found');\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error switching to curl provider:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle('get-stored-credentials', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return {\n hasGeminiKey: false,\n hasGroqKey: false,\n hasOpenaiKey: false,\n hasClaudeKey: false,\n hasNativelyKey: false,\n googleServiceAccountPath: null,\n sttProvider: 'none',\n groqSttModel: 'whisper-large-v3-turbo',\n hasSttGroqKey: false,\n hasSttOpenaiKey: false,\n hasDeepgramKey: false,\n hasElevenLabsKey: false,\n hasAzureKey: false,\n azureRegion: 'eastus',\n hasIbmWatsonKey: false,\n ibmWatsonRegion: 'us-south',\n hasSonioxKey: false,\n hasTavilyKey: false,\n sttGroqKey: '',\n sttOpenaiKey: '',\n sttDeepgramKey: '',\n sttElevenLabsKey: '',\n sttAzureKey: '',\n sttIbmKey: '',\n sttSonioxKey: '',\n };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\n 'fetch-provider-models',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg =\n error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle(\n 'set-provider-preferred-model',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n },\n );\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\n 'set-stt-provider',\n async (\n _,\n provider:\n | 'none'\n | 'google'\n | 'groq'\n | 'openai'\n | 'deepgram'\n | 'elevenlabs'\n | 'azure'\n | 'ibmwatson'\n | 'soniox'\n | 'natively',\n ) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting STT provider:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('get-stt-provider', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle('set-groq-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Groq STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-openai-stt-base-url', async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving OpenAI STT base URL:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-deepgram-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Deepgram API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-groq-stt-model', async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Groq STT model:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-elevenlabs-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving ElevenLabs API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Azure API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-azure-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting Azure region:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error('Error saving IBM Watson API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-soniox-api-key', async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error('Error saving Soniox API key:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-ibmwatson-region', async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting IBM Watson region:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\n 'test-stt-connection',\n async (\n _,\n provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox',\n apiKey: string,\n region?: string,\n ) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url =\n 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try {\n ws.send(JSON.stringify({ type: 'CloseStream' }));\n } catch {}\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try {\n ws.close();\n } catch {}\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(\n JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }),\n );\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log(\n '[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.',\n );\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n },\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n },\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (\n CredentialsManager.getInstance().getOpenAiSttBaseUrl() || ''\n ).trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint =\n provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg =\n respData?.error?.message ||\n respData?.detail?.message ||\n respData?.message ||\n error.message ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error('STT connection test failed:', msg);\n return { success: false, error: msg };\n }\n },\n );\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle('local-whisper-get-models', async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle('local-whisper-set-model', async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle('local-whisper-get-channel-config', async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\n 'local-whisper-set-channel-config',\n async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string')\n sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('local-whisper-delete-model', async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-start-download', async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-preload', async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id =\n modelId ||\n SettingsManager.getInstance().get('localWhisperModel') ||\n 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('local-whisper-get-hardware', () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\n 'test-llm-connection',\n async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(\n url,\n {\n contents: [{ parts: [{ text: 'Hello' }] }],\n },\n {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000,\n },\n );\n } else if (provider === 'groq') {\n response = await axios.post(\n 'https://api.groq.com/openai/v1/chat/completions',\n {\n model: 'llama-3.3-70b-versatile',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'openai') {\n response = await axios.post(\n 'https://api.openai.com/v1/chat/completions',\n {\n model: 'gpt-4o-mini',\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000,\n },\n );\n } else if (provider === 'claude') {\n response = await axios.post(\n 'https://api.anthropic.com/v1/messages',\n {\n model: 'claude-sonnet-4-6',\n max_tokens: 10,\n messages: [{ role: 'user', content: 'Hello' }],\n },\n {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n timeout: 15000,\n },\n );\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error('LLM connection test failed:', safeInfo);\n const rawMsg =\n error?.response?.data?.error?.message ||\n error?.response?.data?.message ||\n (error.response?.data?.error?.type\n ? `${error.response.data.error.type}: ${error.response.data.error.message}`\n : error.message) ||\n 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n },\n );\n\n safeHandle('get-groq-fast-text-mode', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle('set-groq-fast-text-mode', (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach((win) => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-codex-cli-config', () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle('set-codex-cli-config', (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('test-codex-cli', async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({\n ...normalized,\n path: result.resolvedPath,\n });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('set-model', async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle('set-default-model', async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('Error setting default model:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle('get-default-model', async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error('Error getting default model:', error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle('show-model-selector', (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle('hide-model-selector', () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle('toggle-model-selector', (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle('model-selector:close-if-open', () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle('native-audio-status', async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle('get-input-devices', async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle('get-output-devices', async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle('start-audio-test', async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle('stop-audio-test', async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle('set-recognition-language', async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle('start-meeting', async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error('Error starting meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('end-meeting', async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error('Error ending meeting:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('get-recent-meetings', async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle('get-meeting-details', async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle('update-meeting-title', async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle('update-meeting-summary', async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle('seed-demo', async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle('flush-database', async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle('open-external', async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl =\n parsed.protocol === 'https:' &&\n parsed.hostname === 'mail.google.com' &&\n parsed.pathname === '/mail/';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl =\n parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', {\n protocol: parsed.protocol,\n hostname: parsed.hostname,\n });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle('generate-assist', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n if (insight) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n insight,\n 'Assist',\n );\n } catch (_) {}\n }\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\n 'generate-what-to-say',\n async (\n _,\n question?: string,\n imagePaths?: string[],\n options?: { promptInstruction?: string },\n ) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (\n !Array.isArray(imagePaths) ||\n imagePaths.length > 5 ||\n imagePaths.some(\n (imagePath) => typeof imagePath !== 'string' || imagePath.trim().length === 0,\n )\n ) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload',\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`,\n );\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`,\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const {\n getScreenUnderstandingService,\n } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable: credentials.anyLocalVisionProviderConfigured?.() ?? false,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus =\n sur.status === 'available'\n ? 'available'\n : sur.status === 'failed'\n ? 'failed'\n : 'not_available';\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(\n question,\n 0.8,\n validatedImagePaths,\n {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction:\n typeof options?.promptInstruction === 'string'\n ? options.promptInstruction\n : undefined,\n },\n );\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'What to Answer',\n );\n } catch (_) {}\n }\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error',\n };\n }\n },\n );\n\n safeHandle('generate-clarify', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', {\n error:\n 'Could not generate a clarifying question. Try again after some audio context is available.',\n mode: 'clarify',\n });\n } else {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n clarification,\n 'Clarify',\n );\n } catch (_) {}\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(\n `[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`,\n { errorClass: err?.name },\n );\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle('generate-code-hint', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-code-hint',\n 'technical',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (hint) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n hint,\n 'Code Hint',\n );\n } catch (_) {}\n }\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle('generate-brainstorm', async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0 ? imagePaths : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(\n `[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`,\n );\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(\n `[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`,\n );\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(\n resolvedImagePaths,\n 'generate-brainstorm',\n 'balanced',\n );\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement,\n );\n if (script) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n script,\n 'Brainstorm',\n );\n } catch (_) {}\n }\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle('get-action-button-mode', () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle('set-action-button-mode', (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle('generate-follow-up', async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n if (refined) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n refined,\n 'Follow Up',\n );\n } catch (_) {}\n }\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle('generate-recap', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n if (summary) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n summary,\n 'Recap',\n );\n } catch (_) {}\n }\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle('generate-follow-up-questions', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n if (questions) {\n try {\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n questions,\n 'Follow-Up Questions',\n );\n } catch (_) {}\n }\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle('submit-manual-question', async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n if (answer) {\n try {\n PhoneMirrorService.getInstance().publishUserMessage(crypto.randomUUID(), question);\n PhoneMirrorService.getInstance().publishAssistantMessage(\n crypto.randomUUID(),\n answer,\n 'Answer',\n );\n } catch (_) {}\n }\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle('get-intelligence-context', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode(),\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle('reset-intelligence', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle('dynamic-action:accept', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: {\n actionId: action.id,\n actionType: action.type,\n modeTemplateType: action.modeTemplateType,\n },\n });\n } catch {\n /* non-fatal */\n }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:dismiss', async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle('dynamic-action:list', async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\n 'test-inject-transcript',\n async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript(\n {\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n },\n true,\n );\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n },\n );\n\n safeHandle('test-get-mode-context', async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Service Account Selection\n safeHandle('select-service-account', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error('Error selecting service account:', error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle('theme:get-mode', () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme(),\n };\n });\n\n safeHandle('theme:set-mode', (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle('calendar-connect', async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error('Calendar auth error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('calendar-disconnect', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle('get-calendar-status', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle('get-upcoming-events', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle('calendar-refresh', async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle('generate-followup-email', async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(\n geminiPrompt,\n undefined,\n undefined,\n true,\n groqPrompt,\n );\n\n return emailBody;\n } catch (error: any) {\n console.error('Error generating follow-up email:', error);\n throw error;\n }\n });\n\n safeHandle('extract-emails-from-transcript', async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error('Error extracting emails:', error);\n return [];\n }\n });\n\n safeHandle('get-calendar-attendees', async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees\n .map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || '',\n }))\n .filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error('Error getting calendar attendees:', error);\n return [];\n }\n });\n\n safeHandle(\n 'open-mailto',\n async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error('Error opening mailto:', error);\n return { success: false, error: error.message };\n }\n },\n );\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\n 'rag:query-meeting',\n async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log('[RAG] Not ready, falling back to regular chat');\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (\n !ragManager.isMeetingProcessed(meetingId) &&\n !ragManager.isLiveIndexingActive(meetingId)\n ) {\n console.log(\n `[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`,\n );\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { meetingId, chunk });\n }\n\n event.sender.send('rag:stream-complete', { meetingId });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error('[RAG] Query error:', error);\n event.sender.send('rag:stream-error', { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n },\n );\n\n // Query live meeting with JIT RAG\n safeHandle('rag:query-live', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { live: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { live: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || '';\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error('[RAG] Live query error:', error);\n event.sender.send('rag:stream-error', { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle('rag:query-global', async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send('rag:stream-chunk', { global: true, chunk });\n }\n\n event.sender.send('rag:stream-complete', { global: true });\n return { success: true };\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send('rag:stream-error', { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\n 'rag:cancel-query',\n async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n },\n );\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle('rag:get-queue-status', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle('rag:retry-embeddings', async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-resume', async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-status', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears,\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle('profile:set-mode', async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-profile', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle('profile:select-file', async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }],\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle('profile:upload-jd', async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return {\n success: false,\n error: 'Knowledge engine not initialized. Please ensure API keys are configured.',\n };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:delete-jd', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:research-company', async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const {\n TavilySearchProvider,\n } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const {\n NativelySearchProvider,\n } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(\n new NativelySearchProvider(nativelyKey, trialToken ?? undefined),\n );\n console.log(\n '[IPC] Company research: using Natively API search (no Tavily key configured)',\n );\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD\n ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n }\n : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:generate-negotiation', async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return {\n success: false,\n error:\n 'Pro license required. Please activate a license key to use Profile Intelligence features.',\n };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return {\n success: false,\n error:\n 'Could not generate negotiation script. Ensure a resume and job description are uploaded.',\n };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-negotiation-state', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:reset-negotiation', async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle('profile:get-notes', async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-notes', async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('profile:get-persona', async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle('profile:save-persona', async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle('set-tavily-api-key', async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle('set-overlay-opacity', async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle('permissions:check', async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone');\n const screen = systemPreferences.getMediaAccessStatus('screen');\n return { microphone: mic, screen, platform: 'darwin' };\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform };\n });\n\n safeHandle('permissions:request-mic', async () => {\n if (process.platform !== 'darwin') return true;\n try {\n return await systemPreferences.askForMediaAccess('microphone');\n } catch {\n return false;\n }\n });\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle('modes:get-all', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle('modes:get-active', async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle('modes:create', async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\n 'modes:update',\n async (\n _,\n id: string,\n updates: { name?: string; templateType?: string; customContext?: string },\n ) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:set-active', async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance()\n .getModes()\n .find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch {\n /* non-fatal — session may not exist during startup */\n }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach((win) => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch {\n /* non-fatal */\n }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch {\n /* non-fatal */\n }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:get-reference-files', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle('modes:upload-reference-file', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt',\n '.md',\n '.markdown',\n '.json',\n '.csv',\n '.tsv',\n '.xml',\n '.html',\n '.htm',\n '.log',\n '.pdf',\n '.docx',\n '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n {\n name: 'Text & Documents',\n extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'],\n },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return {\n success: false,\n error: 'Could not read the selected file. It may have moved or been deleted.',\n };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error:\n 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) =>\n setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),\n ),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(\n mammoth.extractRawText({ path: filePath }),\n PARSE_TIMEOUT_MS,\n 'DOCX parse',\n );\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xff && probe[1] === 0xfe) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xfe && probe[1] === 0xff) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (\n probe.length >= 3 &&\n probe[0] === 0xef &&\n probe[1] === 0xbb &&\n probe[2] === 0xbf\n ) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error(\n '[IPC] modes:upload-reference-file parser error:',\n parseErr?.message ?? parseErr,\n );\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return {\n success: false,\n error: 'Could not read the selected file. Please try a different file or contact support.',\n };\n }\n });\n\n safeHandle('modes:delete-reference-file', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle('modes:get-note-sections', async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\n 'modes:add-note-section',\n async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle(\n 'modes:update-note-section',\n async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n },\n );\n\n safeHandle('modes:delete-note-section', async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle('modes:remove-all-note-sections', async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) {\n /* settings window may not exist yet */\n }\n });\n\n safeHandle('phone-mirror:get-info', async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle('phone-mirror:enable', async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({\n exposeOnLan: !!exposeOnLan,\n persist: true,\n });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle('phone-mirror:disable', async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle('phone-mirror:set-lan', async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle('phone-mirror:rotate-token', async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // Stealth screenshot capture triggered from the phone UI.\n // Takes a screenshot on the PC (adding it to the screenshot queue so it can\n // be used in the next AI prompt), then broadcasts an ack so the phone shows\n // a confirmation toast. The image is NOT sent to the phone — the phone is\n // just a remote shutter; the screenshot stays on the desktop for AI use.\n safeHandle('phone-mirror:push-screenshot', async (_, screenshotPath?: string) => {\n try {\n const imgPath = screenshotPath || (await appState.takeScreenshot(false));\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n return { success: true, path: imgPath };\n } catch (e: any) {\n console.error('[IPC] phone-mirror:push-screenshot error:', e);\n return { error: e?.message || 'failed to capture screenshot' };\n }\n });\n\n // Route commands sent by the phone browser back to the Electron renderer so\n // the existing action system (global-shortcut events, chat stream) handles\n // them without duplicating logic.\n PhoneMirrorService.getInstance().onPhoneCommand(async (cmd) => {\n const win = appState.getMainWindow();\n\n if (cmd.type === 'action') {\n // Re-use the same global-shortcut dispatch path the keyboard uses.\n // This keeps phone actions identical to key-triggered stealth actions.\n const allWindows = BrowserWindow.getAllWindows();\n allWindows.forEach((w) => {\n if (!w.isDestroyed()) w.webContents.send('global-shortcut', { action: cmd.action });\n });\n } else if (cmd.type === 'chat') {\n // Stream a phone-initiated chat through the LLM exactly like gemini-chat-stream\n // but without requiring a renderer event sender. Tokens are pushed directly to\n // the phone over WebSocket; desktop renderer also receives them so both views\n // stay in sync.\n const myStreamId = ++_chatStreamId;\n const message = cmd.message;\n const phoneMirror = PhoneMirrorService.getInstance();\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Capture rolling context BEFORE adding the new user message — same ordering\n // as gemini-chat-stream so Recap / Follow Up / What to Answer see phone turns.\n let context: string | undefined;\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) context = snap;\n } catch (ctxErr) {\n console.warn('[PhoneMirror] Failed to capture pre-turn context:', ctxErr);\n }\n\n intelligenceManager.addTranscript(\n { text: message, speaker: 'user', timestamp: Date.now(), final: true },\n true,\n );\n\n try {\n phoneMirror.publishUserMessage(String(myStreamId), message);\n } catch (_) {}\n // Notify renderer so it can display the incoming phone message too.\n win?.webContents.send('phone-mirror:incoming-chat', {\n message,\n streamId: String(myStreamId),\n });\n\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const stream = llmHelper.streamChat(message, undefined, context, CHAT_MODE_PROMPT);\n let full = '';\n for await (const token of stream) {\n // Bail if a newer stream has taken over (phone or desktop chat).\n if (_chatStreamId !== myStreamId) {\n console.log(\n `[PhoneMirror] phone-chat ${myStreamId} superseded by ${_chatStreamId}, stopping.`,\n );\n return;\n }\n // Cancel early if all phones have disconnected — no point burning LLM\n // tokens when nobody is receiving them and we have no desktop renderer\n // context to show the result in either.\n if (!phoneMirror.hasClients() && win?.isDestroyed()) break;\n try {\n phoneMirror.publishToken(String(myStreamId), token);\n } catch (_) {}\n win?.webContents.send('gemini-stream-token', token);\n full += token;\n }\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishDone(String(myStreamId), full);\n } catch (_) {}\n win?.webContents.send('gemini-stream-done');\n if (full.trim().length > 0) {\n intelligenceManager.addAssistantMessage(full);\n intelligenceManager.logUsage('chat', message, full);\n }\n }\n } catch (err: any) {\n console.error('[PhoneMirror] phone-chat stream error:', err);\n if (_chatStreamId === myStreamId) {\n try {\n phoneMirror.publishError(String(myStreamId), err?.message || 'stream error');\n } catch (_) {}\n win?.webContents.send('gemini-stream-error', err?.message || 'stream error');\n }\n }\n } else if (cmd.type === 'screenshot') {\n // Stealth screenshot: capture on PC → add to screenshot queue → ack to phone.\n // The image is NOT sent to the phone — it stays on the desktop for AI use.\n // The phone simply acts as a remote shutter button.\n try {\n await appState.takeScreenshot(false);\n PhoneMirrorService.getInstance().publishAck(\n 'screenshot',\n 'Screenshot captured — queued for AI',\n );\n } catch (e: any) {\n console.error('[PhoneMirror] phone screenshot request failed:', e);\n PhoneMirrorService.getInstance().publishAck('screenshot', 'Screenshot failed');\n }\n }\n });\n}\n"}, "6f6c1f1563f39c6b370ef94896d6c03ebe008dd0": {"t": 1785754444.081195, "n": 169184, "h": "3fe02b3fcd5d2a48", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\nimport { SkillsManager } from \"./services/SkillsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\nimport { CHAT_MODE_PROMPT, HARD_SYSTEM_PROMPT } from \"./llm/prompts\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, { length: intelligenceManager.getLastAssistantMessage()?.length ?? 0 });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE = /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE = /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean, skipModeInjection?: boolean, skillId?: string }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? \"I was developed by Evin John.\"\n : (IDENTITY_PROBE_RE.test(message) ? \"I'm Natively, an AI assistant.\" : null);\n if (identityHit) {\n intelligenceManager.addTranscript({ text: message, speaker: 'user', timestamp: Date.now(), final: true }, true);\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", identityHit);\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to capture pre-turn context:\", ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(`[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`);\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n let systemPromptOverride: string | undefined = options?.skipSystemPrompt ? \"\" : CHAT_MODE_PROMPT;\n let ignoreKnowledgeMode = !!options?.ignoreKnowledgeMode;\n\n // When a skill is invoked, the skill's instructions REPLACE the chat-mode\n // framing and we skip RAG knowledge-mode retrieval — the skill is the\n // entire intent, mixing in the regular context just dilutes it.\n if (options?.skillId) {\n const skill = SkillsManager.getInstance().getSkill(options.skillId);\n if (!skill) {\n event.sender.send(\"gemini-stream-error\", `Skill not found: ${options.skillId}`);\n event.sender.send(\"gemini-stream-done\");\n return null;\n }\n systemPromptOverride = `${HARD_SYSTEM_PROMPT}\\n\\n## ACTIVE SKILL\\n${SkillsManager.getInstance().buildPromptBlock(skill)}`;\n ignoreKnowledgeMode = true;\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, systemPromptOverride, ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-meeting-retention\", async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle(\"set-meeting-retention\", async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-provider-data-scopes\", async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle(\"set-provider-data-scopes\", async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set(['transcript', 'screenshots', 'reference_files', 'profile_history', 'embeddings', 'post_call_summary']);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-screen-understanding-mode\", async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\"set-screen-understanding-mode\", async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-technical-interview-vision-first\", async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle(\"set-technical-interview-vision-first\", async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle(\"get-technical-interview-direct-vision\", async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle(\"set-technical-interview-direct-vision\", async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle(\"local-whisper-get-models\", async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle(\"local-whisper-set-model\", async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle(\"local-whisper-get-channel-config\", async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\"local-whisper-set-channel-config\", async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string') sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-delete-model\", async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-start-download\", async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-preload\", async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id = modelId || SettingsManager.getInstance().get('localWhisperModel') || 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-get-hardware\", () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error(\"LLM connection test failed:\", safeInfo);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle(\"model-selector:close-if-open\", () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl = parsed.protocol === 'https:' && parsed.hostname === 'mail.google.com' && parsed.pathname === '/mail/';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl = parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', { protocol: parsed.protocol, hostname: parsed.hostname });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[], options?: { promptInstruction?: string; skillId?: string }) => {\n try {\n let screenContext: any;\n let resolvedSkill: { id: string; name: string; promptBlock: string } | undefined;\n let skillName: string | undefined;\n\n if (options?.skillId) {\n const skill = SkillsManager.getInstance().getSkill(options.skillId);\n if (!skill) {\n return {\n answer: null,\n question: question || 'unknown',\n error: `Skill not found: ${options.skillId}`,\n };\n }\n skillName = skill.name;\n resolvedSkill = {\n id: skill.id,\n name: skill.name,\n promptBlock: SkillsManager.getInstance().buildPromptBlock(skill),\n };\n }\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (!Array.isArray(imagePaths) || imagePaths.length > 5 || imagePaths.some(imagePath => typeof imagePath !== 'string' || imagePath.trim().length === 0)) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload'\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`);\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const { getScreenUnderstandingService } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable: credentials.anyLocalVisionProviderConfigured?.() ?? false,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus = sur.status === 'available' ? 'available' : (sur.status === 'failed' ? 'failed' : 'not_available');\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, validatedImagePaths, {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction: typeof options?.promptInstruction === 'string' ? options.promptInstruction : undefined,\n activeSkill: resolvedSkill,\n });\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n skillName,\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(`[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`, { errorClass: err?.name });\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`);\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(resolvedImagePaths, 'generate-code-hint', 'technical');\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`);\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(resolvedImagePaths, 'generate-brainstorm', 'balanced');\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle(\"dynamic-action:accept\", async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: { actionId: action.id, actionType: action.type, modeTemplateType: action.modeTemplateType },\n });\n } catch { /* non-fatal */ }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle(\"dynamic-action:dismiss\", async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch { /* non-fatal */ }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle(\"dynamic-action:list\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\"test-inject-transcript\", async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n }, true);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-get-mode-context\", async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-persona\", async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-persona\", async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch { /* non-fatal — session may not exist during startup */ }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch { /* non-fatal */ }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch { /* non-fatal */ }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt', '.md', '.markdown', '.json', '.csv', '.tsv', '.xml', '.html', '.htm', '.log',\n '.pdf', '.docx', '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return { success: false, error: 'Could not read the selected file. It may have moved or been deleted.' };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error: 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(mammoth.extractRawText({ path: filePath }), PARSE_TIMEOUT_MS, 'DOCX parse');\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xFF && probe[1] === 0xFE) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xFE && probe[1] === 0xFF) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (probe.length >= 3 && probe[0] === 0xEF && probe[1] === 0xBB && probe[2] === 0xBF) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error('[IPC] modes:upload-reference-file parser error:', parseErr?.message ?? parseErr);\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return { success: false, error: 'Could not read the selected file. Please try a different file or contact support.' };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n\n // ── Skills ────────────────────────────────────────────────────────────\n // Local SKILL.md instructions discovered from userData/skills + bundled\n // built-ins. SkillsManager handles disk IO; these handlers are thin.\n\n safeHandle(\"skills:list\", async () => {\n return SkillsManager.getInstance().listSkills();\n });\n\n safeHandle(\"skills:get\", async (_, id: string) => {\n if (typeof id !== 'string' || id.trim().length === 0) return null;\n return SkillsManager.getInstance().getSkill(id);\n });\n\n // listSkills() re-reads from disk each call, so refresh is just a re-list.\n safeHandle(\"skills:refresh\", async () => {\n return SkillsManager.getInstance().listSkills();\n });\n\n safeHandle(\"skills:open-folder\", async () => {\n return SkillsManager.getInstance().openSkillsFolder();\n });\n}\n"}, "18c9f0d048d441cc3e096d942adc712d20237d38": {"t": 1785754447.4314747, "n": 166780, "h": "ca9098fc3ae007e1", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\nimport { CHAT_MODE_PROMPT } from \"./llm/prompts\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears premium-only context when the pro license is lost.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n const db = DatabaseManager.getInstance();\n db.setActiveMode(null);\n db.clearProfilePersona?.();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n llmHelper?.setPersonaPrompt?.('');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Premium-only context cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, { length: intelligenceManager.getLastAssistantMessage()?.length ?? 0 });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE = /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE = /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? \"I was developed by Evin John.\"\n : (IDENTITY_PROBE_RE.test(message) ? \"I'm Natively, an AI assistant.\" : null);\n if (identityHit) {\n intelligenceManager.addTranscript({ text: message, speaker: 'user', timestamp: Date.now(), final: true }, true);\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", identityHit);\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to capture pre-turn context:\", ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(`[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`);\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt ? \"\" : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, systemPromptOverride, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-meeting-retention\", async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle(\"set-meeting-retention\", async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-provider-data-scopes\", async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle(\"set-provider-data-scopes\", async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set(['transcript', 'screenshots', 'reference_files', 'profile_history', 'embeddings', 'post_call_summary']);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-screen-understanding-mode\", async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\"set-screen-understanding-mode\", async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-technical-interview-vision-first\", async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle(\"set-technical-interview-vision-first\", async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle(\"get-technical-interview-direct-vision\", async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle(\"set-technical-interview-direct-vision\", async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle(\"local-whisper-get-models\", async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle(\"local-whisper-set-model\", async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle(\"local-whisper-get-channel-config\", async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\"local-whisper-set-channel-config\", async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string') sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-delete-model\", async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-start-download\", async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-preload\", async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id = modelId || SettingsManager.getInstance().get('localWhisperModel') || 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-get-hardware\", () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error(\"LLM connection test failed:\", safeInfo);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle(\"model-selector:close-if-open\", () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl = parsed.protocol === 'https:' && parsed.hostname === 'mail.google.com' && parsed.pathname === '/mail/';\n // x-apple.systempreferences is a macOS-only URI scheme. Allowing it on\n // Windows let renderer regressions hand Windows shell an unknown\n // protocol → Microsoft Store popup (issue #252). Gate the allowlist on\n // the actual platform so the IPC layer is the last line of defense.\n const allowedSystemSettingsUrl = parsed.protocol === 'x-apple.systempreferences:' && process.platform === 'darwin';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', { protocol: parsed.protocol, hostname: parsed.hostname });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[], options?: { promptInstruction?: string }) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (!Array.isArray(imagePaths) || imagePaths.length > 5 || imagePaths.some(imagePath => typeof imagePath !== 'string' || imagePath.trim().length === 0)) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload'\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`);\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const { getScreenUnderstandingService } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable: credentials.anyLocalVisionProviderConfigured?.() ?? false,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus = sur.status === 'available' ? 'available' : (sur.status === 'failed' ? 'failed' : 'not_available');\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, validatedImagePaths, {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction: typeof options?.promptInstruction === 'string' ? options.promptInstruction : undefined,\n });\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(`[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`, { errorClass: err?.name });\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`);\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(resolvedImagePaths, 'generate-code-hint', 'technical');\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`);\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(resolvedImagePaths, 'generate-brainstorm', 'balanced');\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle(\"dynamic-action:accept\", async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: { actionId: action.id, actionType: action.type, modeTemplateType: action.modeTemplateType },\n });\n } catch { /* non-fatal */ }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle(\"dynamic-action:dismiss\", async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch { /* non-fatal */ }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle(\"dynamic-action:list\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\"test-inject-transcript\", async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n }, true);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-get-mode-context\", async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-persona\", async () => {\n try {\n if (!isProOrTrialActive()) return { success: false, content: '', error: 'pro_required' };\n const content = DatabaseManager.getInstance().getPersona();\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(content);\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-persona\", async (_, content: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n if (typeof content !== 'string') return { success: false, error: 'invalid_persona' };\n const trimmed = content.trim().slice(0, 4000);\n DatabaseManager.getInstance().savePersona(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setPersonaPrompt) llmHelper.setPersonaPrompt(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch { /* non-fatal — session may not exist during startup */ }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch { /* non-fatal */ }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch { /* non-fatal */ }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt', '.md', '.markdown', '.json', '.csv', '.tsv', '.xml', '.html', '.htm', '.log',\n '.pdf', '.docx', '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return { success: false, error: 'Could not read the selected file. It may have moved or been deleted.' };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error: 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(mammoth.extractRawText({ path: filePath }), PARSE_TIMEOUT_MS, 'DOCX parse');\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xFF && probe[1] === 0xFE) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xFE && probe[1] === 0xFF) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (probe.length >= 3 && probe[0] === 0xEF && probe[1] === 0xBB && probe[2] === 0xBF) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error('[IPC] modes:upload-reference-file parser error:', parseErr?.message ?? parseErr);\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return { success: false, error: 'Could not read the selected file. Please try a different file or contact support.' };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "c41e3298bccca61cbe7ef80c649e3fac327d790c": {"t": 1785754449.4271142, "n": 165198, "h": "319bdb2f6a8f5536", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\nimport { CHAT_MODE_PROMPT } from \"./llm/prompts\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response received`, { length: result?.length ?? 0 });\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message`, { length: intelligenceManager.getLastAssistantMessage()?.length ?? 0 });\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE = /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE = /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? \"I was developed by Evin John.\"\n : (IDENTITY_PROBE_RE.test(message) ? \"I'm Natively, an AI assistant.\" : null);\n if (identityHit) {\n intelligenceManager.addTranscript({ text: message, speaker: 'user', timestamp: Date.now(), final: true }, true);\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", identityHit);\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to capture pre-turn context:\", ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(`[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`);\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt ? \"\" : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, systemPromptOverride, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-meeting-retention\", async () => {\n return SettingsManager.getInstance().get('meetingRetention') ?? 'forever';\n });\n\n safeHandle(\"set-meeting-retention\", async (_, retention: 'forever' | '7d' | '30d' | 'never') => {\n if (!['forever', '7d', '30d', 'never'].includes(retention)) {\n return { success: false, error: 'invalid_retention' };\n }\n SettingsManager.getInstance().set('meetingRetention', retention);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('meeting-retention-changed', retention);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-provider-data-scopes\", async () => {\n return SettingsManager.getInstance().get('providerDataScopes') ?? {};\n });\n\n safeHandle(\"set-provider-data-scopes\", async (_, scopes: Record<string, boolean>) => {\n if (!scopes || typeof scopes !== 'object') {\n return { success: false, error: 'invalid_scopes' };\n }\n const allowedKeys = new Set(['transcript', 'screenshots', 'reference_files', 'profile_history', 'embeddings', 'post_call_summary']);\n const sanitized: Record<string, boolean> = {};\n for (const [key, value] of Object.entries(scopes)) {\n if (allowedKeys.has(key) && typeof value === 'boolean') {\n sanitized[key] = value;\n }\n }\n SettingsManager.getInstance().set('providerDataScopes', sanitized as any);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('provider-data-scopes-changed', sanitized);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-screen-understanding-mode\", async () => {\n return SettingsManager.getInstance().getScreenUnderstandingMode();\n });\n\n safeHandle(\"set-screen-understanding-mode\", async (_, mode: 'vision_first' | 'vision_only' | 'private_vision') => {\n if (!['vision_first', 'vision_only', 'private_vision'].includes(mode)) {\n return { success: false, error: 'invalid_mode' };\n }\n SettingsManager.getInstance().setScreenUnderstandingMode(mode);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('screen-understanding-mode-changed', mode);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-technical-interview-vision-first\", async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n\n safeHandle(\"set-technical-interview-vision-first\", async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n // Legacy alias for renderer builds that still call the old IPC name.\n // Maps the deprecated technicalInterviewDirectVision channel onto the new\n // technicalInterviewVisionFirst getter/setter so old renderer builds keep working.\n safeHandle(\"get-technical-interview-direct-vision\", async () => {\n return SettingsManager.getInstance().getTechnicalInterviewVisionFirst();\n });\n safeHandle(\"set-technical-interview-direct-vision\", async (_, enabled: boolean) => {\n if (typeof enabled !== 'boolean') {\n return { success: false, error: 'invalid_value' };\n }\n SettingsManager.getInstance().set('technicalInterviewVisionFirst', enabled);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('technical-interview-vision-first-changed', enabled);\n }\n });\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n const { trial_token, ...safeData } = data;\n return { ok: true, ...safeData, hasToken: Boolean(data.trial_token) };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // SECURITY FIX (P0): Return masked keys only, never raw API keys.\n // The hasSttGroqKey boolean tells UI if key exists — no raw key needed.\n sttGroqKey: creds.groqSttApiKey ? `sk-...${creds.groqSttApiKey.slice(-4)}` : '',\n sttOpenaiKey: creds.openAiSttApiKey ? `sk-...${creds.openAiSttApiKey.slice(-4)}` : '',\n sttDeepgramKey: creds.deepgramApiKey ? `sk-...${creds.deepgramApiKey.slice(-4)}` : '',\n sttElevenLabsKey: creds.elevenLabsApiKey ? `sk-...${creds.elevenLabsApiKey.slice(-4)}` : '',\n sttAzureKey: creds.azureApiKey ? `sk-...${creds.azureApiKey.slice(-4)}` : '',\n sttIbmKey: creds.ibmWatsonApiKey ? `sk-...${creds.ibmWatsonApiKey.slice(-4)}` : '',\n sttSonioxKey: creds.sonioxApiKey ? `sk-...${creds.sonioxApiKey.slice(-4)}` : '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n // SECURITY FIX (P0): Error fallback returns masked keys, not raw strings\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle(\"local-whisper-get-models\", async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle(\"local-whisper-set-model\", async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle(\"local-whisper-get-channel-config\", async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\"local-whisper-set-channel-config\", async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string') sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-delete-model\", async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-start-download\", async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-preload\", async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id = modelId || SettingsManager.getInstance().get('localWhisperModel') || 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-get-hardware\", () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error(\"LLM connection test failed:\", safeInfo);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle(\"model-selector:close-if-open\", () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n if (typeof url !== 'string') {\n console.warn('[IPC] Blocked invalid open-external request', { reason: 'non-string' });\n return;\n }\n\n const parsed = new URL(url);\n const allowedWebUrl = parsed.protocol === 'https:' && parsed.hostname === 'mail.google.com' && parsed.pathname === '/mail/';\n const allowedSystemSettingsUrl = parsed.protocol === 'x-apple.systempreferences:';\n\n if (allowedWebUrl || allowedSystemSettingsUrl) {\n await shell.openExternal(url);\n } else {\n console.warn('[IPC] Blocked open-external request', { protocol: parsed.protocol, hostname: parsed.hostname });\n }\n } catch {\n console.warn('[IPC] Invalid URL in open-external');\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n //\n // VISION-FIRST: image paths are validated and forwarded to IntelligenceManager\n // which routes them through the vision provider fallback chain.\n // LEGACY OCR PATH DISABLED: the previous build called ScreenContextService.captureScreenFromPath\n // here to run Tesseract OCR before answering. That path is now removed from the runtime —\n // Natively answers from the image directly via a vision-capable provider. Do not re-introduce\n // OCR here unless a future explicit OCR-only mode is reintroduced.\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[], options?: { promptInstruction?: string }) => {\n try {\n let screenContext: any;\n let screenContextStatus: 'not_available' | 'available' | 'failed' = 'not_available';\n let visionProviderUsed: string | undefined;\n let visionModelUsed: string | undefined;\n let visionAttempts: number | undefined;\n let visionFailureReason: string | undefined;\n\n const validatedImagePaths: string[] | undefined = imagePaths?.length ? [] : undefined;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n if (!Array.isArray(imagePaths) || imagePaths.length > 5 || imagePaths.some(imagePath => typeof imagePath !== 'string' || imagePath.trim().length === 0)) {\n console.warn('[IPC] generate-what-to-say: malformed image path payload rejected');\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: 'Invalid image path payload'\n };\n }\n\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-what-to-say: invalid image path rejected: ${validation.reason}`);\n return {\n answer: null,\n question: question || 'unknown',\n screenContextStatus,\n error: `Invalid image path: ${validation.reason}`\n };\n }\n validatedImagePaths!.push(imagePath);\n }\n\n // Vision-first: run the ScreenUnderstandingService so the image is hashed, optimized,\n // and routed through the vision provider fallback chain. The structured result becomes\n // the screenContext that PromptAssembler consumes.\n try {\n const { getScreenUnderstandingService } = require('./services/screen/ScreenUnderstandingService');\n const { CredentialsManager } = require('./services/CredentialsManager');\n const sus = getScreenUnderstandingService();\n const settings = SettingsManager.getInstance();\n const credentials = CredentialsManager.getInstance();\n const providerScopes = settings.get('providerDataScopes') || {};\n\n const sur = await sus.understand({\n modeId: 'what-to-say',\n transcript: question,\n userAction: 'what_to_say',\n qualityMode: 'balanced',\n imagePaths: validatedImagePaths,\n screenUnderstandingMode: settings.getScreenUnderstandingMode(),\n technicalInterviewVisionFirst: settings.getTechnicalInterviewVisionFirst(),\n providerPolicy: {\n localOnly: settings.getScreenUnderstandingMode() === 'private_vision',\n allowScreenshots: providerScopes.screenshots !== false,\n visionAvailable: credentials.anyVisionProviderConfigured?.() ?? true,\n localVisionAvailable: credentials.anyLocalVisionProviderConfigured?.() ?? false,\n },\n });\n\n screenContext = sur.status === 'available' ? sur : undefined;\n screenContextStatus = sur.status === 'available' ? 'available' : (sur.status === 'failed' ? 'failed' : 'not_available');\n visionProviderUsed = sur.providerUsed;\n visionModelUsed = sur.modelUsed;\n visionAttempts = Array.isArray(sur.attempts) ? sur.attempts.length : undefined;\n visionFailureReason = sur.failureReason;\n } catch (sErr: any) {\n screenContextStatus = 'failed';\n console.warn('[IPC] generate-what-to-say: ScreenUnderstandingService failed', {\n errorClass: sErr?.name || 'Error',\n });\n }\n }\n\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, validatedImagePaths, {\n skipCooldown: process.env.NODE_ENV === 'test',\n screenContext,\n promptInstruction: typeof options?.promptInstruction === 'string' ? options.promptInstruction : undefined,\n });\n return {\n answer,\n question: question || 'inferred from context',\n screenContextStatus,\n visionProviderUsed,\n visionModelUsed,\n visionAttempts,\n visionFailureReason,\n imageCount: validatedImagePaths?.length || 0,\n usedImageInput: Boolean(validatedImagePaths?.length),\n };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Shared helper: validate, then run images through the vision-first ImageOptimizer\n // so downstream provider calls send compressed JPEG payloads instead of raw retina PNGs.\n // Falls back to the original paths if optimization fails — image input is more important\n // than payload size, so a Sharp failure must not block the request.\n async function optimizeImagesForVision(\n paths: string[],\n handlerLabel: string,\n profile: 'fast' | 'balanced' | 'technical' | 'best' = 'technical',\n ): Promise<string[]> {\n if (paths.length === 0) return paths;\n try {\n const { getImageOptimizer } = require('./services/screen/ImageOptimizer');\n const optimizer = getImageOptimizer();\n const optimized: string[] = [];\n for (const p of paths) {\n try {\n const out = await optimizer.optimize(p, { profile, provider: 'openai', cacheKey: p });\n optimized.push(out.path);\n } catch (err: any) {\n console.warn(`[IPC] ${handlerLabel}: image optimization failed for ${p}, using original`, { errorClass: err?.name });\n optimized.push(p);\n }\n }\n return optimized;\n } catch {\n return paths;\n }\n }\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-code-hint: invalid image path rejected: ${validation.reason}`);\n return { error: `Invalid image path: ${validation.reason}`, hint: null };\n }\n }\n }\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n // VISION-FIRST: optimize the screenshot(s) with Sharp before they reach the LLM,\n // using the 'technical' profile so code text stays sharp at 1536px.\n const optimizedPaths = await optimizeImagesForVision(resolvedImagePaths, 'generate-code-hint', 'technical');\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const screenshotQueue = appState.getScreenshotQueue();\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : screenshotQueue;\n\n // SECURITY (P0): Validate image paths if provided from renderer\n if (imagePaths && imagePaths.length > 0) {\n const { app } = require('electron');\n const { validateImagePath } = require('./utils/curlUtils');\n const userDataDir = app.getPath('userData');\n\n for (const imagePath of imagePaths) {\n const validation = validateImagePath(imagePath, userDataDir);\n if (!validation.isValid) {\n console.warn(`[IPC] generate-brainstorm: invalid image path rejected: ${validation.reason}`);\n return { error: `Invalid image path: ${validation.reason}`, script: null };\n }\n }\n }\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n // VISION-FIRST: balanced profile (1280px) — brainstorm doesn't need code-sharp text.\n const optimizedPaths = await optimizeImagesForVision(resolvedImagePaths, 'generate-brainstorm', 'balanced');\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n optimizedPaths.length > 0 ? optimizedPaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // Phase 3 — Dynamic Actions IPC. Accept/dismiss/list. The action emission\n // direction is push-only (intelligence-dynamic-action channel from main →\n // renderer); these handlers are the renderer → main control plane.\n safeHandle(\"dynamic-action:accept\", async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n const action = intelligenceManager.acceptDynamicAction(actionId);\n if (!action) return { success: false, error: 'not_found' };\n // Phase 6 — telemetry on accept (no transcript, no evidence body).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'dynamic_action_accepted',\n sessionId: action.sessionId,\n modeId: action.modeId,\n properties: { actionId: action.id, actionType: action.type, modeTemplateType: action.modeTemplateType },\n });\n } catch { /* non-fatal */ }\n // Caller (renderer) is expected to follow up with a normal Ask-AI call\n // using action.promptInstruction. We return the action so the renderer\n // can populate the answer prompt without a second round-trip.\n return { success: true, action };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle(\"dynamic-action:dismiss\", async (_, actionId: string) => {\n try {\n if (typeof actionId !== 'string' || !actionId) {\n return { success: false, error: 'invalid_action_id' };\n }\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.dismissDynamicAction(actionId);\n // Phase 6 — telemetry on dismiss.\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({ name: 'dynamic_action_dismissed', properties: { actionId } });\n } catch { /* non-fatal */ }\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error' };\n }\n });\n\n safeHandle(\"dynamic-action:list\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return { success: true, actions: intelligenceManager.getActiveDynamicActions() };\n } catch (error: any) {\n return { success: false, error: error?.message ?? 'internal_error', actions: [] };\n }\n });\n\n safeHandle(\"test-inject-transcript\", async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n }, true);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-get-mode-context\", async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n // BUG-MODE-BLEEDING fix: clear mode-specific session context BEFORE switching modes\n // so Interview mode resume/JD context doesn't bleed into the new mode's responses.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr) appStateIntMgr.clearSessionContext();\n } catch { /* non-fatal — session may not exist during startup */ }\n\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeMode = id ? ModesManager.getInstance().getActiveMode() : null;\n const activeName = activeMode?.name ?? null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n // Phase 3 — re-bind dynamic action engine so the new mode's trigger pack\n // takes effect immediately. New (sessionId, modeId) pair flushes the per-\n // session store inside DynamicActionEngine, killing any old-mode candidates.\n try {\n const appStateIntMgr = appState.getIntelligenceManager();\n if (appStateIntMgr && activeMode) {\n appStateIntMgr.setDynamicActionContext({\n sessionId: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,\n modeId: activeMode.id,\n modeTemplateType: activeMode.templateType,\n });\n } else if (appStateIntMgr && !id) {\n appStateIntMgr.clearDynamicActionContext();\n }\n } catch { /* non-fatal */ }\n // Phase 6 — mode_switched telemetry (no PII).\n try {\n const { telemetryService } = require('./services/telemetry/TelemetryService');\n telemetryService.track({\n name: 'mode_switched',\n modeId: activeMode?.id,\n properties: { modeTemplateType: activeMode?.templateType, cleared: !id },\n });\n } catch { /* non-fatal */ }\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n // Server-side allow-list. The dialog filter is a hint to users — never\n // trust it for validation, since the user can rename a file or the\n // filter can be bypassed by selecting \"All Files\" in the dialog UI.\n // Plain-text formats parse trivially; PDF and DOCX go through their\n // dedicated parsers below.\n const ALLOWED_EXTENSIONS = new Set([\n '.txt', '.md', '.markdown', '.json', '.csv', '.tsv', '.xml', '.html', '.htm', '.log',\n '.pdf', '.docx', '.doc',\n ]);\n // 10 MiB per file. Anything larger is almost always a database dump,\n // a media file, or a misclicked archive; the modes layer would just\n // truncate it to ~40 KB anyway via MAX_TOTAL_CHARS.\n const MAX_FILE_BYTES = 10 * 1024 * 1024;\n\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'json', 'csv', 'xml', 'html', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n if (!ALLOWED_EXTENSIONS.has(ext)) {\n // Friendly, actionable message — UI surfaces this to the user.\n return {\n success: false,\n error: `Unsupported file type \"${ext || 'none'}\". Supported formats: TXT, MD, JSON, CSV, XML, HTML, LOG, PDF, DOCX, DOC. For resumes and job descriptions, use Profile Intelligence under Settings instead.`,\n };\n }\n\n // Pre-flight stat. Use lstat so we don't auto-follow symlinks — a\n // symlink to /dev/zero or a network mount that lies about size would\n // otherwise hang the renderer-IPC reply forever via readFileSync.\n let stats: ReturnType<typeof fs.lstatSync>;\n try {\n stats = fs.lstatSync(filePath);\n } catch {\n return { success: false, error: 'Could not read the selected file. It may have moved or been deleted.' };\n }\n if (!stats.isFile()) {\n return {\n success: false,\n error: 'Selected path is not a regular file (it may be a symlink, device, or directory). Pick a real document file.',\n };\n }\n if (stats.size > MAX_FILE_BYTES) {\n const mb = (stats.size / (1024 * 1024)).toFixed(1);\n return {\n success: false,\n error: `File is ${mb} MB; the maximum is 10 MB. Trim the file or split it into smaller reference documents.`,\n };\n }\n\n // Wrap the parser branches in a per-call timeout. pdf-parse and mammoth\n // have both hung historically on malformed input or zip-bomb DOCX —\n // 15 s is generous for a 10 MiB document.\n const PARSE_TIMEOUT_MS = 15_000;\n function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {\n return Promise.race([\n p,\n new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),\n ]);\n }\n\n let content = '';\n try {\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data: any = await withTimeout<any>(parser.getText(), PARSE_TIMEOUT_MS, 'PDF parse');\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2: any = await withTimeout<any>(mammoth.extractRawText({ path: filePath }), PARSE_TIMEOUT_MS, 'DOCX parse');\n content = result2.value;\n } else {\n // Plain-text family. Read raw bytes first so we can detect text\n // encoding from a leading byte-order-mark before deciding whether\n // a null byte is binary noise or a legitimate UTF-16 zero-pad.\n const probe = fs.readFileSync(filePath, { encoding: null });\n if (probe.length === 0) {\n return { success: false, error: `\"${fileName}\" is empty.` };\n }\n // BOM-aware decode. UTF-16 files have many embedded null bytes; we\n // must NOT treat those as a binary-rename signal.\n if (probe.length >= 2 && probe[0] === 0xFF && probe[1] === 0xFE) {\n content = probe.subarray(2).toString('utf16le');\n } else if (probe.length >= 2 && probe[0] === 0xFE && probe[1] === 0xFF) {\n // UTF-16 BE → swap pairs then decode as utf16le.\n const swapped = Buffer.allocUnsafe(probe.length - 2);\n for (let i = 2; i + 1 < probe.length; i += 2) {\n swapped[i - 2] = probe[i + 1];\n swapped[i - 1] = probe[i];\n }\n content = swapped.toString('utf16le');\n } else if (probe.length >= 3 && probe[0] === 0xEF && probe[1] === 0xBB && probe[2] === 0xBF) {\n content = probe.subarray(3).toString('utf8');\n } else {\n // No BOM. Sniff the first 2 KiB for a null byte — that's the\n // strongest signal of a renamed binary.\n const sniffWindow = probe.subarray(0, Math.min(2048, probe.length));\n if (sniffWindow.includes(0)) {\n return {\n success: false,\n error: `\"${fileName}\" looks like a binary file even though its extension is ${ext}. Re-save the file as plain text or pick a supported document format.`,\n };\n }\n content = probe.toString('utf8');\n }\n }\n } catch (parseErr: any) {\n // Parser-specific failures (timeout, malformed PDF, zip-bomb DOCX).\n // Log detail to main-process; return a generic message.\n console.error('[IPC] modes:upload-reference-file parser error:', parseErr?.message ?? parseErr);\n return {\n success: false,\n error: `Could not parse \"${fileName}\". The file may be corrupt, password-protected, or in an unsupported variant of ${ext}.`,\n };\n }\n\n if (!content || content.trim().length === 0) {\n return {\n success: false,\n error: `\"${fileName}\" parsed to empty text. The file may be password-protected, image-only, or corrupt.`,\n };\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n // Do not leak raw error.message to the renderer (may contain absolute\n // paths or library internals). Return a generic message; the detail is\n // already in the main-process log above.\n return { success: false, error: 'Could not read the selected file. Please try a different file or contact support.' };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "43ae233d7148237d46b3d5221d8e2b95ff87b54e": {"t": 1785754451.1803472, "n": 143076, "h": "3f5701003f139dcd", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\nimport { CHAT_MODE_PROMPT } from \"./llm/prompts\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE = /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE = /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? \"I was developed by Evin John.\"\n : (IDENTITY_PROBE_RE.test(message) ? \"I'm Natively, an AI assistant.\" : null);\n if (identityHit) {\n intelligenceManager.addTranscript({ text: message, speaker: 'user', timestamp: Date.now(), final: true }, true);\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", identityHit);\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to capture pre-turn context:\", ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(`[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`);\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt ? \"\" : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, systemPromptOverride, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle(\"local-whisper-get-models\", async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle(\"local-whisper-set-model\", async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle(\"local-whisper-get-channel-config\", async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\"local-whisper-set-channel-config\", async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string') sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-delete-model\", async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-start-download\", async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-preload\", async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id = modelId || SettingsManager.getInstance().get('localWhisperModel') || 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-get-hardware\", () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error(\"LLM connection test failed:\", safeInfo);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle(\"model-selector:close-if-open\", () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths, {\n skipCooldown: process.env.NODE_ENV === 'test',\n });\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n console.error('[IPC] generate-what-to-say error:', error);\n return {\n answer: null,\n question: question || 'unknown',\n error: error?.message || 'unknown_error'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-inject-transcript\", async (_, segment: { speaker: string; text: string; timestamp?: number; final?: boolean }) => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n speaker: segment.speaker,\n text: segment.text,\n timestamp: segment.timestamp ?? Date.now(),\n final: segment.final ?? true,\n }, true);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-get-mode-context\", async () => {\n try {\n if (process.env.NODE_ENV !== 'test') return { success: false, error: 'test_only' };\n const { ModesManager } = require('./services/ModesManager');\n const manager = ModesManager.getInstance();\n return {\n success: true,\n block: manager.buildActiveModeContextBlock(),\n suffix: manager.getActiveModeSystemPromptSuffix(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data = await parser.getText();\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "fcfb496b2af3ab14a3cb3215beeb6415a1d53f61": {"t": 1785754453.4510536, "n": 141762, "h": "938d25c98064a802", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\nimport { CHAT_MODE_PROMPT } from \"./llm/prompts\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE = /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE = /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? \"I was developed by Evin John.\"\n : (IDENTITY_PROBE_RE.test(message) ? \"I'm Natively, an AI assistant.\" : null);\n if (identityHit) {\n intelligenceManager.addTranscript({ text: message, speaker: 'user', timestamp: Date.now(), final: true }, true);\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", identityHit);\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to capture pre-turn context:\", ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(`[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`);\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt ? \"\" : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, systemPromptOverride, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n // ==========================================\n // Local Whisper STT Handlers\n // ==========================================\n\n const activeWhisperDownloads = new Set<string>();\n\n safeHandle(\"local-whisper-get-models\", async () => {\n try {\n const { getAvailableModels } = require('./audio/whisper/modelManager');\n const models = getAvailableModels();\n const activeModelId = SettingsManager.getInstance().get('localWhisperModel') ?? '';\n return { models, activeModelId };\n } catch (e: any) {\n console.error('[IPC] local-whisper-get-models error:', e.message);\n return { models: [], activeModelId: '' };\n }\n });\n\n safeHandle(\"local-whisper-set-model\", async (_, modelId: string) => {\n try {\n SettingsManager.getInstance().set('localWhisperModel', modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Per-channel model overrides (mic / system audio). When enabled, the two\n // STT instances pick their own model via these slots. When disabled, both\n // fall back to localWhisperModel (the existing global setting).\n safeHandle(\"local-whisper-get-channel-config\", async () => {\n const sm = SettingsManager.getInstance();\n return {\n enabled: !!sm.get('localWhisperPerChannelEnabled'),\n micModelId: sm.get('localWhisperModelMic') ?? '',\n systemModelId: sm.get('localWhisperModelSystem') ?? '',\n globalModelId: sm.get('localWhisperModel') ?? '',\n };\n });\n\n safeHandle(\"local-whisper-set-channel-config\", async (_, cfg: { enabled?: boolean; micModelId?: string; systemModelId?: string }) => {\n try {\n const sm = SettingsManager.getInstance();\n if (typeof cfg?.enabled === 'boolean') sm.set('localWhisperPerChannelEnabled', cfg.enabled);\n if (typeof cfg?.micModelId === 'string') sm.set('localWhisperModelMic', cfg.micModelId);\n if (typeof cfg?.systemModelId === 'string') sm.set('localWhisperModelSystem', cfg.systemModelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-delete-model\", async (_, modelId: string) => {\n try {\n const { deleteModel } = require('./audio/whisper/modelManager');\n deleteModel(modelId);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-start-download\", async (event, modelId: string) => {\n if (activeWhisperDownloads.has(modelId)) {\n return { success: false, error: 'already-downloading' };\n }\n activeWhisperDownloads.add(modelId);\n try {\n const { Worker } = require('worker_threads');\n const nodePath = require('path');\n const { buildWorkerInitMessage } = require('./audio/whisper/inferenceConfig');\n const workerPath = nodePath.join(__dirname, 'audio', 'whisper', 'whisperWorker.js');\n const w = new Worker(workerPath);\n const sender = event.sender;\n w.on('message', (msg: any) => {\n if (sender.isDestroyed()) return;\n if (msg.type === 'progress') {\n sender.send('local-whisper-download-progress', { modelId, progress: msg.progress });\n } else if (msg.type === 'ready') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-complete', { modelId });\n w.terminate();\n } else if (msg.type === 'error') {\n activeWhisperDownloads.delete(modelId);\n sender.send('local-whisper-download-error', { modelId, error: msg.message });\n w.terminate();\n }\n });\n w.on('error', (err: Error) => {\n activeWhisperDownloads.delete(modelId);\n if (!sender.isDestroyed()) {\n sender.send('local-whisper-download-error', { modelId, error: err.message });\n }\n });\n w.postMessage(buildWorkerInitMessage(modelId));\n return { success: true };\n } catch (e: any) {\n activeWhisperDownloads.delete(modelId);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-preload\", async (_, modelId: string) => {\n try {\n const { modelPreloader } = require('./audio/whisper/modelPreloader');\n const { isModelCached } = require('./audio/whisper/modelManager');\n const { resolveInferenceConfig } = require('./audio/whisper/inferenceConfig');\n const { SettingsManager } = require('./services/SettingsManager');\n const id = modelId || SettingsManager.getInstance().get('localWhisperModel') || 'Xenova/whisper-tiny.en';\n // Pass active dtype so the cache check verifies the SPECIFIC ONNX\n // files (e.g. encoder_model.onnx for fp32) are present — not just\n // \"directory non-empty\". Otherwise a v2-cached _quantized.onnx-only\n // directory would be reported \"available\" but trigger a 142MB\n // background fetch on first start().\n const { dtype } = resolveInferenceConfig();\n if (!isModelCached(id, dtype)) {\n return { success: false, reason: 'model-not-cached' };\n }\n modelPreloader.preload(id);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"local-whisper-get-hardware\", () => {\n const { detectHardware } = require('./audio/whisper/hardwareDetect');\n return detectHardware();\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error(\"LLM connection test failed:\", safeInfo);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle(\"model-selector:close-if-open\", () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data = await parser.getText();\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "6a2d312cda0951c8a70e1ba6fb6833900c87079b": {"t": 1785754455.0827339, "n": 136354, "h": "81835dcf92d5ee8f", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\nimport { CHAT_MODE_PROMPT } from \"./llm/prompts\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n // Matches narrow identity/meta probes only. Kept tight so coding/normal asks don't trip it.\n // Prevents the small fast-mode model from over-firing the \"I'm Natively\" canned reply\n // (which used to escape the prompt's hard rule for any ambiguous input).\n const IDENTITY_PROBE_RE = /^\\s*(who\\s+(are|r)\\s+(you|u|this|natively)|what\\s+(are|r)\\s+(you|u)|are\\s+you\\s+(chatgpt|gpt[-\\s]?\\d?|claude|gemini|llama|an?\\s+(ai|bot|llm|model|assistant))|what('?s|\\s+is)\\s+your\\s+(name|model)|which\\s+(ai|model|llm)\\s+are\\s+you|who\\s+(made|built|created|developed|trained)\\s+(you|this|natively)|what\\s+model\\s+(are\\s+you|do\\s+you\\s+use)|introduce\\s+yourself)\\s*\\??\\s*$/i;\n const CREATOR_PROBE_RE = /^\\s*(who\\s+(made|built|created|developed|trained)\\s+(you|this|natively))\\s*\\??\\s*$/i;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n const intelligenceManager = appState.getIntelligenceManager();\n\n // Identity probe short-circuit — bypasses the LLM entirely so small models can't\n // reframe the canned reply or misfire it on coding asks (the original bug).\n // Regex is `^...$` anchored, so non-probe questions cannot match.\n if (!imagePaths?.length && typeof message === 'string') {\n const identityHit = CREATOR_PROBE_RE.test(message)\n ? \"I was developed by Evin John.\"\n : (IDENTITY_PROBE_RE.test(message) ? \"I'm Natively, an AI assistant.\" : null);\n if (identityHit) {\n intelligenceManager.addTranscript({ text: message, speaker: 'user', timestamp: Date.now(), final: true }, true);\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n // Guard against a newer chat stream having taken over while we were computing\n // the canned reply — matches the protection the LLM path uses around its token\n // loop. Prevents cross-stream UI bleed.\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} (identity probe) superseded by ${_chatStreamId}, skipping emit.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", identityHit);\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), identityHit); } catch (_) { /* noop */ }\n intelligenceManager.addAssistantMessage(identityHit);\n intelligenceManager.logUsage('chat', message, identityHit);\n return null;\n }\n }\n\n // Capture rolling context BEFORE adding the new user message — otherwise the\n // 100s window would echo back the user's just-typed message as both context and\n // question, confusing small models (the \"20-char context\" log line was just an echo).\n let autoContextSnapshot: string | undefined;\n if (!context) {\n try {\n const snap = intelligenceManager.getFormattedContext(100);\n if (snap && snap.trim().length > 0) autoContextSnapshot = snap;\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to capture pre-turn context:\", ctxErr);\n }\n }\n\n // Now add USER message to IntelligenceManager (after context snapshot)\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n if (!context && autoContextSnapshot) {\n context = autoContextSnapshot;\n console.log(`[IPC] Auto-injected 100s context for gemini-chat-stream (${context.length} chars)`);\n }\n\n // Use CHAT_MODE_PROMPT for general chat — bypasses the interview-copilot\n // framing in HARD_SYSTEM_PROMPT/ASSIST_MODE_PROMPT that was causing coding\n // questions to be answered with \"At Aetherbot AI, I was responsible for...\"\n // (resume hijack via CONTEXT_INTELLIGENCE_LAYER's \"you ARE the user\").\n const systemPromptOverride: string | undefined = options?.skipSystemPrompt ? \"\" : CHAT_MODE_PROMPT;\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, systemPromptOverride, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n // CRITICAL: do NOT log the raw axios error — it includes the request config\n // with the Authorization header (full API key) and is dumped verbatim by\n // Node's util.inspect. Strip to a safe shape before logging.\n const safeInfo = {\n provider,\n status: error?.response?.status,\n statusText: error?.response?.statusText,\n code: error?.code,\n message: error?.message,\n responseError: error?.response?.data?.error?.message || error?.response?.data?.message,\n };\n console.error(\"LLM connection test failed:\", safeInfo);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n // ROUND 3 FIX (#4): click-outside close for ModelSelector. With panel-\n // nonactivating + becomesKeyOnlyIfNeeded, the on('blur') auto-close in\n // ModelSelectorWindowHelper fires unreliably (panel may never become key\n // → never receives blur). The overlay's renderer fires this IPC on every\n // mousedown that isn't on the toggle button itself; if the model selector\n // is open, we close it. No-op when closed (toggleWindow handled the open).\n safeHandle(\"model-selector:close-if-open\", () => {\n const win = appState.modelSelectorWindowHelper.getWindow();\n if (win && !win.isDestroyed() && win.isVisible()) {\n appState.modelSelectorWindowHelper.hideWindow();\n }\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data = await parser.getText();\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "1deeabf9af098bbbf23ee204c9e1a8b214507411": {"t": 1785754457.9359214, "n": 132015, "h": "c6224e87aee8c6d2", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active AND has at least one embedded chunk.\n // isLiveIndexingActive() only tells us the indexer is running — it may have\n // received segments but not yet produced queryable embeddings. Calling\n // queryMeeting() with zero chunks throws NO_MEETING_EMBEDDINGS, adding\n // ~300ms of wasted try/catch overhead before the fallback fires.\n if (!ragManager.isLiveIndexingActive('live-meeting-current') || !ragManager.hasLiveChunks()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const { PDFParse } = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const parser = new PDFParse({ data: buffer });\n const data = await parser.getText();\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "06688f81321a5c84290f45351aa394a6968cf771": {"t": 1785754460.5708742, "n": 131600, "h": "6284eca71c91b37b", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\nimport { CodexCliService } from \"./services/CodexCliService\";\nimport { SettingsManager } from \"./services/SettingsManager\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch {\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n sm.set('codexCliSandboxMode', normalized.sandboxMode);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n const result = await CodexCliService.validateExecutable(normalized.path);\n // If auto-detection found a different working path, persist it so\n // subsequent chat calls don't re-ENOENT.\n if (result.success && result.resolvedPath && result.resolvedPath !== normalized.path) {\n const updated = CodexCliService.normalizeConfig({ ...normalized, path: result.resolvedPath });\n const sm = SettingsManager.getInstance();\n sm.set('codexCliPath', updated.path);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(updated);\n return { success: true, resolvedPath: result.resolvedPath, config: updated };\n }\n return result;\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "de46f5cd8ccd254ce68079c4bac7659f1aca6d60": {"t": 1785754462.547587, "n": 131106, "h": "5690befd8b8463bb", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-codex-cli-config\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return llmHelper.getCodexCliConfig();\n } catch (error: any) {\n const { CodexCliService } = require('./services/CodexCliService');\n return CodexCliService.normalizeConfig({});\n }\n });\n\n safeHandle(\"set-codex-cli-config\", (_, config: any) => {\n try {\n const { CodexCliService } = require('./services/CodexCliService');\n const { SettingsManager } = require('./services/SettingsManager');\n const normalized = CodexCliService.normalizeConfig(config || {});\n const sm = SettingsManager.getInstance();\n sm.set('codexCliEnabled', normalized.enabled);\n sm.set('codexCliPath', normalized.path);\n sm.set('codexCliModel', normalized.model);\n sm.set('codexCliFastModel', normalized.fastModel);\n sm.set('codexCliTimeoutMs', normalized.timeoutMs);\n appState.processingHelper.getLLMHelper().setCodexCliConfig(normalized);\n return { success: true, config: normalized };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"test-codex-cli\", async (_, config?: any) => {\n try {\n const { CodexCliService } = require('./services/CodexCliService');\n const current = appState.processingHelper.getLLMHelper().getCodexCliConfig();\n const normalized = CodexCliService.normalizeConfig({ ...current, ...(config || {}) });\n return await CodexCliService.validateExecutable(normalized.path);\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n"}, "78486d6162a9262990ad1bac33ec0e6b777cc6f6": {"t": 1785754464.2747016, "n": 129469, "h": "d26d19f35f448e5e", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\nimport { TRIAL_SENTINEL_KEY } from \"./config/constants\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(TRIAL_SENTINEL_KEY); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(TRIAL_SENTINEL_KEY);\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === TRIAL_SENTINEL_KEY ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n\n"}, "92403c220ad9ed23e1567ade2a6a0570ce24309e": {"t": 1785754466.4137397, "n": 129392, "h": "c7408810c2e9fd53", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n openAiSttBaseUrl: creds.openAiSttBaseUrl || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-base-url\", async (_, url: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttBaseUrl(url);\n // Reconfigure the active pipeline so the new endpoint is used immediately,\n // matching the behavior of azure/ibmwatson region setters.\n await appState.reconfigureSttProvider();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT base URL:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n let openAiEndpoint = 'https://api.openai.com/v1/audio/transcriptions';\n if (provider === 'openai') {\n // If a custom OpenAI-compatible base URL is configured, test against it.\n const { CredentialsManager } = require('./services/CredentialsManager');\n const customBase = (CredentialsManager.getInstance().getOpenAiSttBaseUrl() || '').trim();\n if (customBase) {\n const trimmed = customBase.replace(/\\/+$/, '');\n openAiEndpoint = /\\/v\\d+$/.test(trimmed)\n ? `${trimmed}/audio/transcriptions`\n : `${trimmed}/v1/audio/transcriptions`;\n }\n }\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : openAiEndpoint;\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n\n"}, "cd5efb7873897658bca8910f55cc9e0feb3eb264": {"t": 1785754470.1920235, "n": 127971, "h": "51b39417fa8d4eb7", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\nimport { PhoneMirrorService } from \"./services/PhoneMirrorService\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n // Centered variant: keeps horizontal center fixed during width changes.\n // Used by code-expansion animations to prevent the top pill from sliding sideways.\n safeHandle(\n \"update-content-dimensions-centered\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n const senderWebContents = event.sender\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n if (overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id) {\n appState.getWindowHelper().setOverlayDimensionsCentered(width, height)\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // Mirror to phone (no-op if PhoneMirrorService isn't running).\n try { PhoneMirrorService.getInstance().publishUserMessage(String(myStreamId), message); } catch (_) { /* noop */ }\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n try { PhoneMirrorService.getInstance().publishToken(String(myStreamId), token); } catch (_) { /* noop */ }\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n try { PhoneMirrorService.getInstance().publishDone(String(myStreamId), fullResponse); } catch (_) { /* noop */ }\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n try { PhoneMirrorService.getInstance().publishError(String(myStreamId), streamError?.message || \"Unknown streaming error\"); } catch (_) { /* noop */ }\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n\n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n\n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => { }); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => { });\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // -----------------------------------------------------------------------\n // Phone Mirror — stream live AI responses to a paired phone over WS.\n // -----------------------------------------------------------------------\n\n // Push status updates to the renderer whenever the service starts/stops\n // or a phone connects/disconnects. Idempotent — multiple windows can listen.\n PhoneMirrorService.getInstance().onStatusChange((info) => {\n const win = appState.getMainWindow();\n win?.webContents.send('phone-mirror:status', info);\n try {\n const settingsWin = (appState as any).settingsWindowHelper?.getWindow?.();\n settingsWin?.webContents?.send('phone-mirror:status', info);\n } catch (_) { /* settings window may not exist yet */ }\n });\n\n safeHandle(\"phone-mirror:get-info\", async () => {\n return PhoneMirrorService.getInstance().snapshot();\n });\n\n safeHandle(\"phone-mirror:enable\", async (_, exposeOnLan?: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().start({ exposeOnLan: !!exposeOnLan, persist: true });\n } catch (e: any) {\n console.error('[IPC] phone-mirror:enable error:', e);\n return { error: e?.message || 'failed to start phone mirror' };\n }\n });\n\n safeHandle(\"phone-mirror:disable\", async () => {\n await PhoneMirrorService.getInstance().stop({ persist: true });\n return { success: true };\n });\n\n safeHandle(\"phone-mirror:set-lan\", async (_, exposeOnLan: boolean) => {\n try {\n return await PhoneMirrorService.getInstance().setExposeOnLan(!!exposeOnLan);\n } catch (e: any) {\n console.error('[IPC] phone-mirror:set-lan error:', e);\n return { error: e?.message || 'failed to update lan setting' };\n }\n });\n\n safeHandle(\"phone-mirror:rotate-token\", async () => {\n try {\n return await PhoneMirrorService.getInstance().rotateToken();\n } catch (e: any) {\n console.error('[IPC] phone-mirror:rotate-token error:', e);\n return { error: e?.message || 'failed to rotate token' };\n }\n });\n}\n\n"}, "14683cf7c4e3fccdb9304a1f89967f19e77f3583": {"t": 1785754472.5754879, "n": 124715, "h": "2d2082e16d4bf65f", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n safeHandle(\"get-os-version\", async () => {\n const platform = process.platform;\n if (platform === 'darwin') {\n const darwinMajor = parseInt(os.release().split('.')[0] || '0', 10);\n // Darwin 25+ = macOS 26+ (calendar-year scheme), Darwin 20-24 = macOS 11-15\n const macosMajor = darwinMajor >= 25\n ? darwinMajor + 1\n : darwinMajor >= 20\n ? darwinMajor - 9\n : null;\n return macosMajor ? `macOS ${macosMajor}` : `macOS ${os.release()}`;\n }\n if (platform === 'win32') {\n const release = os.release();\n // Windows 11 build starts at 22000\n const majorBuild = parseInt(release.split('.')[2] || '0', 10);\n return majorBuild >= 22000 ? `Windows 11` : `Windows 10`;\n }\n return os.type();\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n}\n\n"}, "fac14faf8a255dd630f55072f9ce990ee2e09ffe": {"t": 1785754474.3212256, "n": 123912, "h": "68dd2497bebbfed0", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // Clears the active mode when the pro license is lost so non-general mode prompts\n // and reference files stop being injected into LLM calls.\n const clearActiveModeOnLicenseLoss = (): void => {\n try {\n const { DatabaseManager } = require('./db/DatabaseManager');\n DatabaseManager.getInstance().setActiveMode(null);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('modes-active-cleared');\n });\n console.log('[IPC] Active mode cleared due to license loss');\n } catch (e) { /* non-fatal */ }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false, trialClaimed: cm.getTrialClaimed() };\n return {\n hasToken: true,\n trialClaimed: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false, trialClaimed: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n clearActiveModeOnLicenseLoss();\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n // Gate: changing templateType to a non-general template requires pro.\n // Also gate if the existing mode is already non-general (editing a pro mode requires pro).\n if (!isProOrTrialActive()) {\n if (updates.templateType && updates.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n const existing = mgr.getModes().find((m: any) => m.id === id);\n if (existing && existing.templateType !== 'general') {\n return { success: false, error: 'pro_required' };\n }\n }\n mgr.updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n}\n\n"}, "83f07a9374c2be0d1d958c3de4d9eea891138bbc": {"t": 1785754475.9412148, "n": 122029, "h": "b4bc28b5287d6116", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false };\n return {\n hasToken: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection.\n // With a valid key, Soniox accepts the config and then silently waits for audio —\n // it never sends a response message. With an invalid key it immediately sends an\n // error message and closes. So the strategy is:\n // • If we receive an error message → fail\n // • If the connection errors at the WS level → fail\n // • If 2.5 s pass after sending the config with no error → success\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n let resolved = false;\n const done = (result: { success: boolean; error?: string }) => {\n if (resolved) return;\n resolved = true;\n try { ws.close(); } catch { }\n resolve(result);\n };\n\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n // Hard connect timeout — server unreachable\n const connectTimeout = setTimeout(() => {\n done({ success: false, error: 'Connection timed out' });\n }, 10000);\n\n ws.on('open', () => {\n clearTimeout(connectTimeout);\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n // Give Soniox 2.5 s to reject the key; silence means the key is valid\n setTimeout(() => done({ success: true }), 2500);\n });\n\n ws.on('message', (msg: any) => {\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n done({ success: false, error: `${res.error_code}: ${res.error_message}` });\n }\n // Non-error message is unexpected but treat as success\n } catch {\n // Unparseable message — treat as success\n }\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(connectTimeout);\n done({ success: false, error: err.message || 'Connection failed' });\n });\n\n ws.on('close', (code: number) => {\n // Abnormal close before we resolved means the server rejected us\n if (!resolved && code !== 1000) {\n done({ success: false, error: `Server closed connection (code ${code})` });\n }\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n}\n\n"}, "5d9583619cf581801e1fac097e9547918d547f3f": {"t": 1785754477.626099, "n": 120934, "h": "88ffdc496862d62a", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateLicense(key);\n if (result?.success) {\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n }\n return result;\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n // Notify all windows so the license UI (ProGate, settings) refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n // Open the launcher's SettingsOverlay on a specific tab (callable from any window)\n safeHandle(\"settings:open-tab\", (_, tab: string) => {\n const launcherWin = appState.getWindowHelper().getLauncherWindow();\n if (launcherWin && !launcherWin.isDestroyed()) {\n launcherWin.webContents.send('settings:open-tab', tab);\n launcherWin.show();\n launcherWin.focus();\n }\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false };\n return {\n hasToken: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n const timeout = setTimeout(() => {\n ws.close();\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n // Send a minimal config to validate the API key\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n });\n\n ws.on('message', (msg: any) => {\n clearTimeout(timeout);\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n resolve({ success: false, error: `${res.error_code}: ${res.error_message}` });\n } else {\n resolve({ success: true });\n }\n } catch {\n resolve({ success: true });\n }\n ws.close();\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Profile Custom Notes\n // ==========================================\n\n safeHandle(\"profile:get-notes\", async () => {\n try {\n const content = DatabaseManager.getInstance().getCustomNotes();\n return { success: true, content };\n } catch (error: any) {\n return { success: false, content: '', error: error.message };\n }\n });\n\n safeHandle(\"profile:save-notes\", async (_, content: string) => {\n try {\n // Enforce a max length of 4000 chars to prevent prompt bloat\n const trimmed = typeof content === 'string' ? content.slice(0, 4000) : '';\n DatabaseManager.getInstance().saveCustomNotes(trimmed);\n\n // Propagate to orchestrator (premium path) and LLMHelper (all-provider path)\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator?.setCustomNotes) orchestrator.setCustomNotes(trimmed);\n\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper?.setCustomNotes) llmHelper.setCustomNotes(trimmed);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n\n // ==========================================\n // Modes IPC Handlers\n // ==========================================\n\n safeHandle(\"modes:get-all\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const mgr = ModesManager.getInstance();\n const modes = mgr.getModes();\n // Attach reference file counts\n return modes.map((m: any) => ({\n ...m,\n referenceFileCount: mgr.getReferenceFiles(m.id).length,\n }));\n } catch (e: any) {\n console.error('[IPC] modes:get-all error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:get-active\", async () => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getActiveMode();\n } catch (e: any) {\n console.error('[IPC] modes:get-active error:', e);\n return null;\n }\n });\n\n safeHandle(\"modes:create\", async (_, params: { name: string; templateType: string }) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n const mode = ModesManager.getInstance().createMode({\n name: params.name,\n templateType: params.templateType as any,\n });\n return { success: true, mode };\n } catch (e: any) {\n console.error('[IPC] modes:create error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update\", async (_, id: string, updates: { name?: string; templateType?: string; customContext?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateMode(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete\", async (_, id: string) => {\n try {\n if (!isProOrTrialActive()) return { success: false, error: 'pro_required' };\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteMode(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:set-active\", async (_, id: string | null) => {\n try {\n // Allow clearing (null) or setting general mode without pro; all other modes require pro\n if (id !== null) {\n const { ModesManager } = require('./services/ModesManager');\n const targetMode = ModesManager.getInstance().getModes().find((m: any) => m.id === id);\n if (targetMode && targetMode.templateType !== 'general' && !isProOrTrialActive()) {\n return { success: false, error: 'pro_required' };\n }\n }\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().setActiveMode(id);\n // Broadcast mode change to all windows so indicators update immediately\n const activeName = id ? (ModesManager.getInstance().getActiveMode()?.name ?? null) : null;\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('mode-changed', { id, name: activeName });\n });\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:set-active error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:get-reference-files\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getReferenceFiles(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-reference-files error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:upload-reference-file\", async (_, modeId: string) => {\n try {\n const result = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Text & Documents', extensions: ['txt', 'md', 'pdf', 'docx', 'doc'] },\n { name: 'All Files', extensions: ['*'] },\n ],\n });\n if (result.canceled || !result.filePaths.length) {\n return { success: false, cancelled: true };\n }\n const filePath = result.filePaths[0];\n const fileName = path.basename(filePath);\n const ext = path.extname(filePath).toLowerCase();\n\n let content = '';\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse');\n const buffer = fs.readFileSync(filePath);\n const data = await pdfParse(buffer);\n content = data.text;\n } else if (ext === '.docx' || ext === '.doc') {\n const mammoth = require('mammoth');\n const result2 = await mammoth.extractRawText({ path: filePath });\n content = result2.value;\n } else {\n content = fs.readFileSync(filePath, 'utf8');\n }\n\n const { ModesManager } = require('./services/ModesManager');\n const file = ModesManager.getInstance().addReferenceFile({ modeId, fileName, content });\n return { success: true, file };\n } catch (e: any) {\n console.error('[IPC] modes:upload-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-reference-file\", async (_, id: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteReferenceFile(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-reference-file error:', e);\n return { success: false, error: e.message };\n }\n });\n\n // ── Note Sections ──────────────────────────────────────────────\n\n safeHandle(\"modes:get-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n return ModesManager.getInstance().getNoteSections(modeId);\n } catch (e: any) {\n console.error('[IPC] modes:get-note-sections error:', e);\n return [];\n }\n });\n\n safeHandle(\"modes:add-note-section\", async (_, modeId: string, title: string, description: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n const section = ModesManager.getInstance().addNoteSection({ modeId, title, description });\n return { success: true, section };\n } catch (e: any) {\n console.error('[IPC] modes:add-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:update-note-section\", async (_, id: string, updates: { title?: string; description?: string }) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().updateNoteSection(id, updates);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:update-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:delete-note-section\", async (_, id: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().deleteNoteSection(id);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:delete-note-section error:', e);\n return { success: false, error: e.message };\n }\n });\n\n safeHandle(\"modes:remove-all-note-sections\", async (_, modeId: string) => {\n try {\n const { ModesManager } = require('./services/ModesManager');\n ModesManager.getInstance().removeAllNoteSections(modeId);\n return { success: true };\n } catch (e: any) {\n console.error('[IPC] modes:remove-all-note-sections error:', e);\n return { success: false, error: e.message };\n }\n });\n}\n\n"}, "e6edcc8a9e55796e8897bb84333c21230454ee3b": {"t": 1785754481.6494672, "n": 111043, "h": "075e664cfe3e52a7", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().activateLicense(key);\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-log-file-path\", async () => {\n try {\n return path.join(app.getPath('documents'), 'natively_debug.log');\n } catch {\n return null;\n }\n });\n\n safeHandle(\"open-log-file\", async () => {\n try {\n const logPath = path.join(app.getPath('documents'), 'natively_debug.log');\n // Ensure the file exists before opening\n if (!fs.existsSync(logPath)) {\n fs.writeFileSync(logPath, '');\n }\n await shell.openPath(logPath);\n return { success: true };\n } catch (e: any) {\n return { success: false, error: e.message };\n }\n });\n\n // Fire-and-forget: renderer forwards its console output to the main-process log file.\n // Only written when verbose logging is enabled.\n ipcMain.on(\"forward-log-to-file\", (_event, level: string, msg: string) => {\n if (!appState.getVerboseLogging()) return;\n const tag = level === 'error' ? '[RENDERER-ERROR]' : level === 'warn' ? '[RENDERER-WARN]' : '[RENDERER]';\n console.log(`${tag} ${msg}`);\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false };\n return {\n hasToken: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n const timeout = setTimeout(() => {\n ws.close();\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n // Send a minimal config to validate the API key\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n });\n\n ws.on('message', (msg: any) => {\n clearTimeout(timeout);\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n resolve({ success: false, error: `${res.error_code}: ${res.error_message}` });\n } else {\n resolve({ success: true });\n }\n } catch {\n resolve({ success: true });\n }\n ws.close();\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n}\n\n"}, "5f6426f51c2a1aa8f03f01585efee4f65d9d6358": {"t": 1785754485.3708422, "n": 110029, "h": "504a476cbf9a1efa", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().activateLicense(key);\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false };\n return {\n hasToken: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'none',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'none', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'none' | 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n // Notify all windows so the settings UI reflects the change immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'none';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('credentials-changed');\n });\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting IBM Watson region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n const timeout = setTimeout(() => {\n ws.close();\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n // Send a minimal config to validate the API key\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n });\n\n ws.on('message', (msg: any) => {\n clearTimeout(timeout);\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n resolve({ success: false, error: `${res.error_code}: ${res.error_message}` });\n } else {\n resolve({ success: true });\n }\n } catch {\n resolve({ success: true });\n }\n ws.close();\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n}\n\n"}, "d8ac2c98e69ae03f3ffdd4b30fd44b59debdfe1e": {"t": 1785754487.446403, "n": 108570, "h": "3c2c12608fe4b8f8", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().activateLicense(key);\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false };\n return {\n hasToken: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'google',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'google', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'google';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n const timeout = setTimeout(() => {\n ws.close();\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n // Send a minimal config to validate the API key\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n });\n\n ws.on('message', (msg: any) => {\n clearTimeout(timeout);\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n resolve({ success: false, error: `${res.error_code}: ${res.error_message}` });\n } else {\n resolve({ success: true });\n }\n } catch {\n resolve({ success: true });\n }\n ws.close();\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n // Pass the real trial token when key is the __trial__ sentinel so the\n // server can authenticate via x-trial-token instead of the invalid key.\n const trialToken = nativelyKey === '__trial__' ? cm.getTrialToken() : undefined;\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey, trialToken ?? undefined));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n}\n\n"}, "5cc011598619aad56194d665c4b043c09ad7d06b": {"t": 1785754490.6103425, "n": 108290, "h": "f71280a2e1537130", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n /**\n * Returns true if the user has an active premium license OR an unexpired free trial.\n * Used to gate profile intelligence features (resume upload, JD upload, company research, etc.).\n */\n const isProOrTrialActive = (): boolean => {\n // 1. Full premium license (Dodo / Gumroad / Natively API subscription)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (LicenseManager.getInstance().isPremium()) return true;\n } catch { /* premium module not available */ }\n\n // 2. Active free trial (token present and not expired)\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return false;\n const expiresAt = cm.getTrialExpiresAt();\n if (!expiresAt) return false;\n return new Date(expiresAt).getTime() > Date.now();\n } catch {\n return false;\n }\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().activateLicense(key);\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n\n safeHandle(\"license:get-details\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getLicenseDetails();\n } catch {\n return { isPremium: false };\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ── Usage cache (60-second TTL, keyed by API key) ──────────────────────────\n const _usageCache = new Map<string, { data: any; ts: number }>();\n const USAGE_CACHE_TTL_MS = 60_000;\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n // Auto-activate Natively Pro for pro/max/ultra API plans.\n // Skips silently if the user already has a Gumroad/Dodo lifetime license.\n if (apiKey) {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const result = await LicenseManager.getInstance().activateWithApiKey(apiKey);\n if (result.success) {\n console.log('[IPC] set-natively-api-key: Pro auto-activated via API plan.');\n // Notify all windows so the license UI refreshes immediately\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: true });\n });\n } else if (result.skipped) {\n console.log('[IPC] set-natively-api-key: existing Gumroad/Dodo license preserved — Pro not overwritten.');\n } else {\n console.log('[IPC] set-natively-api-key: Pro not activated —', result.error);\n }\n } catch (e: any) {\n // LicenseManager not available in this build — non-fatal\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro auto-activation:', e?.message);\n }\n } else {\n // API key was cleared — deactivate any natively_api Pro license so premium is revoked.\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n const lm = LicenseManager.getInstance();\n // Only deactivate if the stored license is from a natively_api subscription.\n // Never touch Gumroad/Dodo lifetime licenses here.\n const details = lm.getLicenseDetails();\n if (details.isPremium && details.provider === 'natively_api') {\n await lm.deactivate();\n console.log('[IPC] set-natively-api-key: key cleared — natively_api Pro license deactivated.');\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('license-status-changed', { isPremium: false });\n });\n }\n } catch (e: any) {\n console.warn('[IPC] set-natively-api-key: LicenseManager unavailable for Pro deactivation on key clear:', e?.message);\n }\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n } finally {\n // Always bust the cache when the key changes so the next usage fetch is fresh\n _usageCache?.clear();\n }\n });\n\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n // Return cached value if it's still fresh\n const cached = _usageCache.get(key);\n if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL_MS) {\n return cached.data;\n }\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n const result = { ok: true, ...data };\n\n // Cache the successful response\n _usageCache.set(key, { data: result, ts: Date.now() });\n return result;\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Allow other handlers to force-invalidate the usage cache (e.g. after key change)\n safeHandle(\"invalidate-natively-usage-cache\", () => {\n _usageCache.clear();\n return { ok: true };\n });\n\n // ── Free Trial IPC ───────────────────────────────────────────────────────────\n\n // Start or resume a free trial. Fetches HWID, calls server, persists token locally.\n safeHandle(\"trial:start\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get hardware ID for HWID-binding\n let hwid = 'unavailable';\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n hwid = LicenseManager.getInstance().getHardwareId() || 'unavailable';\n } catch { /* LicenseManager not available — fall back */ }\n\n const res = await fetch('https://api.natively.software/v1/trial/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hwid }),\n signal: AbortSignal.timeout(10_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n const data = await res.json() as any;\n\n if (data.ok && data.trial_token && !data.expired) {\n cm.setTrialToken(data.trial_token, data.expires_at, data.started_at);\n\n // Auto-configure natively as the model + STT provider during trial\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey('__trial__'); // sentinel — activates natively model routing\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n await appState.reconfigureSttProvider();\n }\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey('__trial__');\n }\n\n return { ok: true, ...data };\n } catch (error: any) {\n console.error('[IPC] trial:start failed:', error);\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Poll the server for live trial status (remaining time + usage counters).\n safeHandle(\"trial:status\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: false, error: 'no_trial_token' };\n\n const res = await fetch('https://api.natively.software/v1/trial/status', {\n headers: { 'x-trial-token': token },\n signal: AbortSignal.timeout(8_000),\n });\n\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n\n return await res.json();\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Return local trial state from credentials (no network call — safe for startup check).\n safeHandle(\"trial:get-local\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const token = cm.getTrialToken();\n if (!token) return { hasToken: false };\n return {\n hasToken: true,\n trialToken: token,\n expiresAt: cm.getTrialExpiresAt(),\n startedAt: cm.getTrialStartedAt(),\n expired: cm.getTrialExpiresAt()\n ? new Date(cm.getTrialExpiresAt()!).getTime() < Date.now()\n : false,\n };\n } catch {\n return { hasToken: false };\n }\n });\n\n // Record the user's post-trial choice in analytics and clean up local state.\n safeHandle(\"trial:convert\", async (_, choice: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const token = CredentialsManager.getInstance().getTrialToken();\n if (!token) return { ok: true }; // no token to report\n\n await fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice }),\n signal: AbortSignal.timeout(5_000),\n }).catch(() => {}); // fire-and-forget — don't block local cleanup on network failure\n\n return { ok: true };\n } catch {\n return { ok: true };\n }\n });\n\n // End trial via BYOK path: wipe Pro-ingested data, clear trial token + natively key.\n safeHandle(\"trial:end-byok\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // 1. Fire-and-forget analytics (non-blocking)\n const token = cm.getTrialToken();\n if (token) {\n fetch('https://api.natively.software/v1/trial/convert', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-trial-token': token },\n body: JSON.stringify({ choice: 'byok' }),\n signal: AbortSignal.timeout(4_000),\n }).catch(() => {});\n }\n\n // 2. Clear trial token\n cm.clearTrialToken();\n\n // 3. Clear the trial sentinel key + revert model / STT to open defaults\n cm.setNativelyApiKey('');\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) llmHelper.setNativelyKey(null);\n await appState.reconfigureSttProvider();\n\n // 4. Deactivate Pro license (removes license.enc)\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n await LicenseManager.getInstance().deactivate();\n } catch { /* LicenseManager not available in this build */ }\n\n // 5. Disable knowledge mode + wipe orchestrator in-memory caches for resume/JD\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore */ }\n\n // 6. Wipe Pro-specific cached data from local SQLite\n // Targets: company dossiers, knowledge docs (+ cascades), resume nodes, user profile\n // NOT wiped: meetings, transcripts, chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n console.log('[IPC] trial:end-byok: Pro data wiped from SQLite');\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:end-byok: SQLite wipe partial error:', dbErr.message);\n }\n\n // 7. Notify all windows to refresh license + model state\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('license-status-changed', { isPremium: false });\n win.webContents.send('trial-ended', { choice: 'byok' });\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:end-byok error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Wipe only Pro profile data (resume + JD + company dossiers) without clearing\n // trial token or natively key. Called automatically when trial expires so that\n // profile intelligence data can't linger in SQLite after the trial window closes.\n safeHandle(\"trial:wipe-profile-data\", async () => {\n try {\n // 1. Disable knowledge mode + wipe orchestrator in-memory caches\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n orchestrator.deleteDocumentsByType(DocType.JD);\n }\n } catch { /* ignore — orchestrator may not be initialised */ }\n\n // 2. Wipe Pro-specific SQLite tables\n // NOT wiped: meetings, transcripts, audio chunks (user's own recordings)\n try {\n const sqliteDb = DatabaseManager.getInstance().getDb();\n if (sqliteDb) {\n sqliteDb.exec(`\n DELETE FROM company_dossiers;\n DELETE FROM knowledge_documents;\n DELETE FROM resume_nodes;\n DELETE FROM user_profile;\n `);\n }\n } catch (dbErr: any) {\n console.warn('[IPC] trial:wipe-profile-data: SQLite wipe partial error:', dbErr.message);\n }\n\n return { success: true };\n } catch (error: any) {\n console.error('[IPC] trial:wipe-profile-data error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'google',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'google', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'google';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n const WebSocket = require('ws');\n const token = apiKey.trim();\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-2&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${token}` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n console.error('[IPC] Deepgram test failed: Connection timed out');\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('unexpected-response', (request: any, response: any) => {\n clearTimeout(timeout);\n const status = response.statusCode;\n let body = '';\n response.on('data', (chunk: Buffer) => { body += chunk.toString(); });\n response.on('end', () => {\n const errMsg = `Unexpected server response: ${status} - ${body}`;\n console.error(`[IPC] Deepgram test failed: ${errMsg}`);\n resolve({ success: false, error: errMsg });\n });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n console.error(`[IPC] Deepgram test error: ${err.message}`);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n const timeout = setTimeout(() => {\n ws.close();\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n // Send a minimal config to validate the API key\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n });\n\n ws.on('message', (msg: any) => {\n clearTimeout(timeout);\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n resolve({ success: false, error: `${res.error_code}: ${res.error_message}` });\n } else {\n resolve({ success: true });\n }\n } catch {\n resolve({ success: true });\n }\n ws.close();\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n // For macOS System Settings, URL() parsing might act differently or we can just check string prefix\n if (url.startsWith('x-apple.systempreferences:')) {\n await shell.openExternal(url);\n return;\n }\n \n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license or free trial for profile features\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license or free trial\n if (enabled && !isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n if (!isProOrTrialActive()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n\n // ── Permissions ──────────────────────────────────────────────\n safeHandle(\"permissions:check\", async () => {\n if (process.platform === 'darwin') {\n const mic = systemPreferences.getMediaAccessStatus('microphone')\n const screen = systemPreferences.getMediaAccessStatus('screen')\n return { microphone: mic, screen, platform: 'darwin' }\n }\n // Windows/Linux: no TCC — permissions handled by OS at install/first-use time\n return { microphone: 'granted', screen: 'granted', platform: process.platform }\n })\n\n safeHandle(\"permissions:request-mic\", async () => {\n if (process.platform !== 'darwin') return true\n try {\n return await systemPreferences.askForMediaAccess('microphone')\n } catch {\n return false\n }\n })\n}\n\n"}, "7c4b31031d3bdb6277862bb539c3488f9505ca06": {"t": 1785754493.9096215, "n": 93652, "h": "d3034083be11f705", "c": "// ipcHandlers.ts\n\nimport { app, ipcMain, shell, dialog, desktopCapturer, systemPreferences, BrowserWindow, screen } from \"electron\"\nimport { AppState } from \"./main\"\nimport { GEMINI_FLASH_MODEL } from \"./IntelligenceManager\"\nimport { DatabaseManager } from \"./db/DatabaseManager\"; // Import Database Manager\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { AudioDevices } from \"./audio/AudioDevices\";\n\n\nimport { RECOGNITION_LANGUAGES, AI_RESPONSE_LANGUAGES } from \"./config/languages\"\n\nexport function initializeIpcHandlers(appState: AppState): void {\n const safeHandle = (channel: string, listener: (event: any, ...args: any[]) => Promise<any> | any) => {\n ipcMain.removeHandler(channel);\n ipcMain.handle(channel, listener);\n };\n\n // --- NEW Test Helper ---\n safeHandle(\"test-release-fetch\", async () => {\n try {\n console.log(\"[IPC] Manual Test Fetch triggered (forcing refresh)...\");\n const { ReleaseNotesManager } = require('./update/ReleaseNotesManager');\n const notes = await ReleaseNotesManager.getInstance().fetchReleaseNotes('latest', true);\n\n if (notes) {\n console.log(\"[IPC] Notes fetched for:\", notes.version);\n const info = {\n version: notes.version || 'latest',\n files: [] as any[],\n path: '',\n sha512: '',\n releaseName: notes.summary,\n releaseNotes: notes.fullBody,\n parsedNotes: notes\n };\n // Send to renderer\n appState.getMainWindow()?.webContents.send(\"update-available\", info);\n return { success: true };\n }\n return { success: false, error: \"No notes returned\" };\n } catch (err: any) {\n console.error(\"[IPC] test-release-fetch failed:\", err);\n return { success: false, error: err.message };\n }\n });\n\n safeHandle(\"license:activate\", async (event, key: string) => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().activateLicense(key);\n } catch (err: any) {\n // Only show generic message if the premium module itself is missing.\n // activateLicense() returns {success:false, error} for all expected failures\n // (bad key, network error, etc.) — it should never throw in normal operation.\n console.error('[IPC] license:activate unexpected error:', err);\n return { success: false, error: 'Premium features not available in this build.' };\n }\n });\n safeHandle(\"license:check-premium\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().isPremium();\n } catch {\n return false;\n }\n });\n // Async variant: performs Dodo server-side revocation check on startup.\n // Returns false only if the server definitively revokes the key.\n // Network errors fail-open (returns cached sync result).\n safeHandle(\"license:check-premium-async\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return await LicenseManager.getInstance().isPremiumAsync();\n } catch {\n return false;\n }\n });\n safeHandle(\"license:deactivate\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n // deactivate() is async — it calls the Dodo server to free the activation slot\n // before removing the local license file. Must be awaited.\n await LicenseManager.getInstance().deactivate();\n // Auto-disable knowledge mode when license is removed\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (orchestrator) {\n orchestrator.setKnowledgeMode(false);\n console.log('[IPC] Knowledge mode auto-disabled due to license deactivation');\n }\n } catch (e) { /* ignore */ }\n } catch { /* LicenseManager not available */ }\n return { success: true };\n });\n safeHandle(\"license:get-hardware-id\", async () => {\n try {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n return LicenseManager.getInstance().getHardwareId();\n } catch {\n return 'unavailable';\n }\n });\n\n safeHandle(\"get-recognition-languages\", async () => {\n return RECOGNITION_LANGUAGES;\n });\n\n safeHandle(\"get-ai-response-languages\", async () => {\n return AI_RESPONSE_LANGUAGES;\n });\n\n safeHandle(\"set-ai-response-language\", async (_, language: string) => {\n // Validate: must be a non-empty string\n if (!language || typeof language !== 'string' || !language.trim()) {\n console.warn('[IPC] set-ai-response-language: invalid or empty language received, ignoring.');\n return { success: false, error: 'Invalid language value' };\n }\n const sanitizedLanguage = language.trim();\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Persist to disk\n CredentialsManager.getInstance().setAiResponseLanguage(sanitizedLanguage);\n // Update live in-memory LLMHelper (same instance used by IntelligenceEngine)\n const llmHelper = appState.processingHelper?.getLLMHelper?.();\n if (llmHelper) {\n llmHelper.setAiResponseLanguage(sanitizedLanguage);\n console.log(`[IPC] AI response language updated to: ${sanitizedLanguage}`);\n } else {\n console.warn('[IPC] set-ai-response-language: processingHelper or LLMHelper not ready, language saved to disk only.');\n }\n return { success: true };\n });\n\n safeHandle(\"get-stt-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttLanguage();\n });\n\n safeHandle(\"get-ai-response-language\", async () => {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getAiResponseLanguage();\n });\n safeHandle(\n \"update-content-dimensions\",\n async (event, { width, height }: { width: number; height: number }) => {\n if (!width || !height) return\n\n const senderWebContents = event.sender\n const settingsWin = appState.settingsWindowHelper.getSettingsWindow()\n const overlayWin = appState.getWindowHelper().getOverlayWindow()\n const launcherWin = appState.getWindowHelper().getLauncherWindow()\n\n if (settingsWin && !settingsWin.isDestroyed() && settingsWin.webContents.id === senderWebContents.id) {\n appState.settingsWindowHelper.setWindowDimensions(settingsWin, width, height)\n } else if (\n overlayWin && !overlayWin.isDestroyed() && overlayWin.webContents.id === senderWebContents.id\n ) {\n // NativelyInterface logic - Resize ONLY the overlay window using dedicated method\n appState.getWindowHelper().setOverlayDimensions(width, height)\n } else if (\n launcherWin && !launcherWin.isDestroyed() && launcherWin.webContents.id === senderWebContents.id\n ) {\n // EC-05 fix: launcher window resize events were previously silently ignored.\n // Log them so that if the launcher ever sends this IPC it's visible in logs.\n console.log(`[IPC] update-content-dimensions: launcher window resize request ${width}x${height} (ignored — launcher has fixed dimensions)`);\n }\n }\n )\n\n safeHandle(\"set-window-mode\", async (event, mode: 'launcher' | 'overlay', inactive?: boolean) => {\n appState.getWindowHelper().setWindowMode(mode, inactive);\n return { success: true };\n })\n\n\n safeHandle(\"delete-screenshot\", async (event, filePath: string) => {\n // Guard: only allow deletion of files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] delete-screenshot: path outside userData rejected:', filePath);\n return { success: false, error: 'Path not allowed' };\n }\n return appState.deleteScreenshot(resolved);\n })\n\n safeHandle(\"take-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // console.error(\"Error taking screenshot:\", error)\n throw error\n }\n })\n\n safeHandle(\"take-selective-screenshot\", async () => {\n try {\n const screenshotPath = await appState.takeSelectiveScreenshot()\n const preview = await appState.getImagePreview(screenshotPath)\n return { path: screenshotPath, preview }\n } catch (error) {\n // EC-04 fix: cast unknown error to Error before accessing .message\n if ((error as Error).message === \"Selection cancelled\") {\n return { cancelled: true }\n }\n throw error\n }\n })\n\n safeHandle(\"get-screenshots\", async () => {\n // console.log({ view: appState.getView() })\n try {\n let previews = []\n if (appState.getView() === \"queue\") {\n previews = await Promise.all(\n appState.getScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n } else {\n previews = await Promise.all(\n appState.getExtraScreenshotQueue().map(async (path) => ({\n path,\n preview: await appState.getImagePreview(path)\n }))\n )\n }\n // previews.forEach((preview: any) => console.log(preview.path))\n return previews\n } catch (error) {\n // console.error(\"Error getting screenshots:\", error)\n throw error\n }\n })\n\n safeHandle(\"toggle-window\", async () => {\n appState.toggleMainWindow()\n })\n\n safeHandle(\"show-window\", async (event, inactive?: boolean) => {\n // Default show main window (Launcher usually)\n appState.showMainWindow(inactive)\n })\n\n safeHandle(\"hide-window\", async () => {\n appState.hideMainWindow()\n })\n\n safeHandle(\"show-overlay\", async () => {\n appState.getWindowHelper().showOverlay();\n })\n\n safeHandle(\"hide-overlay\", async () => {\n appState.getWindowHelper().hideOverlay();\n })\n\n safeHandle(\"get-meeting-active\", async () => {\n return appState.getIsMeetingActive();\n })\n\n safeHandle(\"reset-queues\", async () => {\n try {\n appState.clearQueues()\n // console.log(\"Screenshot queues have been cleared.\")\n return { success: true }\n } catch (error: any) {\n // console.error(\"Error resetting queues:\", error)\n return { success: false, error: error.message }\n }\n })\n\n // Donation IPC Handlers\n safeHandle(\"get-donation-status\", async () => {\n const { DonationManager } = require('./DonationManager');\n const manager = DonationManager.getInstance();\n return {\n shouldShow: manager.shouldShowToaster(),\n hasDonated: manager.getDonationState().hasDonated,\n lifetimeShows: manager.getDonationState().lifetimeShows\n };\n });\n\n safeHandle(\"mark-donation-toast-shown\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().markAsShown();\n return { success: true };\n });\n\n safeHandle(\"set-donation-complete\", async () => {\n const { DonationManager } = require('./DonationManager');\n DonationManager.getInstance().setHasDonated(true);\n return { success: true };\n });\n\n\n // Generate suggestion from transcript - Natively-style text-only reasoning\n safeHandle(\"generate-suggestion\", async (event, context: string, lastQuestion: string) => {\n try {\n const suggestion = await appState.processingHelper.getLLMHelper().generateSuggestion(context, lastQuestion)\n return { suggestion }\n } catch (error: any) {\n // console.error(\"Error generating suggestion:\", error)\n throw error\n }\n })\n\n safeHandle(\"finalize-mic-stt\", async () => {\n appState.finalizeMicSTT();\n });\n\n // IPC handler for analyzing image from file path\n safeHandle(\"analyze-image-file\", async (event, filePath: string) => {\n // Guard: only allow reading files within the app's own userData directory\n const userDataDir = app.getPath('userData');\n const resolved = path.resolve(filePath);\n if (!resolved.startsWith(userDataDir + path.sep)) {\n console.warn('[IPC] analyze-image-file: path outside userData rejected:', filePath);\n throw new Error('Path not allowed');\n }\n try {\n const result = await appState.processingHelper.getLLMHelper().analyzeImageFiles([resolved])\n return result\n } catch (error: any) {\n throw error\n }\n })\n\n safeHandle(\"gemini-chat\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean }) => {\n try {\n const result = await appState.processingHelper.getLLMHelper().chatWithGemini(message, imagePaths, context, options?.skipSystemPrompt);\n\n console.log(`[IPC] gemini - chat response: `, result ? result.substring(0, 50) : \"(empty)\");\n\n // Don't process empty responses\n if (!result || result.trim().length === 0) {\n console.warn(\"[IPC] Empty response from LLM, not updating IntelligenceManager\");\n return \"I apologize, but I couldn't generate a response. Please try again.\";\n }\n\n // Sync with IntelligenceManager so Follow-Up/Recap work\n const intelligenceManager = appState.getIntelligenceManager();\n\n // 1. Add user question to context (as 'user')\n // CRITICAL: Skip refinement check to prevent auto-triggering follow-up logic\n // The user's manual question is a NEW input, not a refinement of previous answer.\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n // 2. Add assistant response and set as last message\n console.log(`[IPC] Updating IntelligenceManager with assistant message...`);\n intelligenceManager.addAssistantMessage(result);\n console.log(`[IPC] Updated IntelligenceManager.Last message: `, intelligenceManager.getLastAssistantMessage()?.substring(0, 50));\n\n // Log Usage\n intelligenceManager.logUsage('chat', message, result);\n\n return result;\n } catch (error: any) {\n // console.error(\"Error in gemini-chat handler:\", error);\n throw error;\n }\n });\n\n // Streaming IPC Handler\n // SECURITY FIX (P0-1): Monotonic stream ID prevents interleaved tokens from concurrent stream requests.\n // Each new invocation increments the ID; any in-flight iteration bails as soon as it detects\n // that a newer stream has taken over.\n let _chatStreamId = 0;\n\n safeHandle(\"gemini-chat-stream\", async (event, message: string, imagePaths?: string[], context?: string, options?: { skipSystemPrompt?: boolean, ignoreKnowledgeMode?: boolean }) => {\n try {\n console.log(\"[IPC] gemini-chat-stream started using LLMHelper.streamChat\");\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Claim a new stream ID — any prior stream will detect this and stop emitting.\n const myStreamId = ++_chatStreamId;\n\n // Update IntelligenceManager with USER message immediately\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.addTranscript({\n text: message,\n speaker: 'user',\n timestamp: Date.now(),\n final: true\n }, true);\n\n let fullResponse = \"\";\n\n // Context Injection for \"Answer\" button (100s rolling window)\n if (!context) {\n // User requested 100 seconds of context for the answer button\n // Logic: If no explicit context provided (like from manual override), auto-inject from IntelligenceManager\n try {\n const autoContext = intelligenceManager.getFormattedContext(100);\n if (autoContext && autoContext.trim().length > 0) {\n context = autoContext;\n console.log(`[IPC] Auto - injected 100s context for gemini - chat - stream(${context.length} chars)`);\n }\n } catch (ctxErr) {\n console.warn(\"[IPC] Failed to auto-inject context:\", ctxErr);\n }\n }\n\n try {\n // USE streamChat which handles routing\n const stream = llmHelper.streamChat(message, imagePaths, context, options?.skipSystemPrompt ? \"\" : undefined, options?.ignoreKnowledgeMode);\n\n for await (const token of stream) {\n // Bail if a newer stream has taken over (user triggered a new request)\n if (_chatStreamId !== myStreamId) {\n console.log(`[IPC] gemini-chat-stream ${myStreamId} superseded by ${_chatStreamId}, stopping.`);\n return null;\n }\n event.sender.send(\"gemini-stream-token\", token);\n fullResponse += token;\n }\n\n // Final check: only send done if we are still the active stream\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-done\");\n\n // Update IntelligenceManager with ASSISTANT message after completion\n if (fullResponse.trim().length > 0) {\n intelligenceManager.addAssistantMessage(fullResponse);\n // Log Usage for streaming chat\n intelligenceManager.logUsage('chat', message, fullResponse);\n }\n }\n\n } catch (streamError: any) {\n console.error(\"[IPC] Streaming error:\", streamError);\n if (_chatStreamId === myStreamId) {\n event.sender.send(\"gemini-stream-error\", streamError.message || \"Unknown streaming error\");\n }\n }\n\n return null; // Return null as data is sent via events\n\n } catch (error: any) {\n console.error(\"[IPC] Error in gemini-chat-stream setup:\", error);\n throw error;\n }\n });\n\n\n\n safeHandle(\"quit-app\", () => {\n app.quit()\n })\n\n safeHandle(\"quit-and-install-update\", async () => {\n try {\n console.log('[IPC] Quit and install update requested')\n await appState.quitAndInstallUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] quit-and-install-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"delete-meeting\", async (_, id: string) => {\n return DatabaseManager.getInstance().deleteMeeting(id);\n });\n\n safeHandle(\"check-for-updates\", async () => {\n try {\n console.log('[IPC] Manual update check requested')\n await appState.checkForUpdates()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] check-for-updates failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n safeHandle(\"download-update\", async () => {\n try {\n console.log('[IPC] Download update requested')\n appState.downloadUpdate()\n return { success: true }\n } catch (err: any) {\n console.error('[IPC] download-update failed:', err)\n return { success: false, error: err.message }\n }\n })\n\n // Window movement handlers\n safeHandle(\"move-window-left\", async () => {\n appState.moveWindowLeft()\n })\n\n safeHandle(\"move-window-right\", async () => {\n appState.moveWindowRight()\n })\n\n safeHandle(\"move-window-up\", async () => {\n appState.moveWindowUp()\n })\n\n safeHandle(\"move-window-down\", async () => {\n appState.moveWindowDown()\n })\n\n safeHandle(\"center-and-show-window\", async () => {\n appState.centerAndShowWindow()\n })\n\n // Window Controls\n safeHandle(\"window-minimize\", async () => {\n appState.getWindowHelper().minimizeWindow();\n });\n\n safeHandle(\"window-maximize\", async () => {\n appState.getWindowHelper().maximizeWindow();\n });\n\n safeHandle(\"window-close\", async () => {\n appState.getWindowHelper().closeWindow();\n });\n\n safeHandle(\"window-is-maximized\", async () => {\n return appState.getWindowHelper().isMainWindowMaximized();\n });\n\n // Settings Window\n safeHandle(\"toggle-settings-window\", (event, { x, y } = {}) => {\n appState.settingsWindowHelper.toggleWindow(x, y)\n })\n\n safeHandle(\"close-settings-window\", () => {\n appState.settingsWindowHelper.closeWindow()\n })\n\n\n\n safeHandle(\"set-undetectable\", async (_, state: boolean) => {\n appState.setUndetectable(state)\n return { success: true }\n })\n\n safeHandle(\"set-disguise\", async (_, mode: 'terminal' | 'settings' | 'activity' | 'none') => {\n appState.setDisguise(mode)\n return { success: true }\n })\n\n safeHandle(\"get-undetectable\", async () => {\n return appState.getUndetectable()\n })\n\n // Adapted from public PR #113 — verify premium interaction\n safeHandle(\"set-overlay-mouse-passthrough\", async (_, enabled: boolean) => {\n appState.setOverlayMousePassthrough(enabled)\n return { success: true }\n })\n\n safeHandle(\"toggle-overlay-mouse-passthrough\", async () => {\n const enabled = appState.toggleOverlayMousePassthrough()\n return { success: true, enabled }\n })\n\n safeHandle(\"get-overlay-mouse-passthrough\", async () => {\n return appState.getOverlayMousePassthrough()\n })\n\n safeHandle(\"get-disguise\", async () => {\n return appState.getDisguise()\n })\n\n safeHandle(\"set-open-at-login\", async (_, openAtLogin: boolean) => {\n app.setLoginItemSettings({\n openAtLogin,\n openAsHidden: false,\n path: app.getPath('exe') // Explicitly point to executable for production reliability\n });\n return { success: true };\n });\n\n safeHandle(\"get-open-at-login\", async () => {\n const settings = app.getLoginItemSettings();\n return settings.openAtLogin;\n });\n\n safeHandle(\"get-verbose-logging\", async () => {\n return appState.getVerboseLogging();\n });\n\n safeHandle(\"set-verbose-logging\", async (_, enabled: boolean) => {\n appState.setVerboseLogging(enabled);\n return { success: true };\n });\n\n safeHandle(\"get-arch\", async () => {\n return process.arch;\n });\n\n // LLM Model Management Handlers\n safeHandle(\"get-current-llm-config\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return {\n provider: llmHelper.getCurrentProvider(),\n model: llmHelper.getCurrentModel(),\n isOllama: llmHelper.isUsingOllama()\n };\n } catch (error: any) {\n // console.error(\"Error getting current LLM config:\", error);\n throw error;\n }\n });\n\n safeHandle(\"get-available-ollama-models\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const models = await llmHelper.getOllamaModels();\n return models;\n } catch (error: any) {\n // console.error(\"Error getting Ollama models:\", error);\n throw error;\n }\n });\n\n safeHandle(\"switch-to-ollama\", async (_, model?: string, url?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToOllama(model, url);\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"force-restart-ollama\", async () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const success = await llmHelper.forceRestartOllama();\n return { success };\n } catch (error: any) {\n console.error(\"Error force restarting Ollama:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle('restart-ollama', async () => {\n try {\n // First try to kill it if it's running\n await appState.processingHelper.getLLMHelper().forceRestartOllama();\n \n // The forceRestartOllama now calls OllamaManager.getInstance().init() internally\n // so we don't need to do it again here.\n \n return true;\n } catch (error: any) {\n console.error(\"[IPC restart-ollama] Failed to restart:\", error);\n return false;\n }\n });\n\n safeHandle(\"ensure-ollama-running\", async () => {\n try {\n const { OllamaManager } = require('./services/OllamaManager');\n await OllamaManager.getInstance().init();\n return { success: true };\n } catch (error: any) {\n return { success: false, message: error.message };\n }\n });\n\n safeHandle(\"switch-to-gemini\", async (_, apiKey?: string, modelId?: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToGemini(apiKey, modelId);\n\n // Persist API key if provided\n if (apiKey) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n }\n\n return { success: true };\n } catch (error: any) {\n // console.error(\"Error switching to Gemini:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Dedicated API key setters (for Settings UI Save buttons)\n safeHandle(\"set-gemini-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGeminiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setApiKey(apiKey);\n\n // CQ-06 fix: cancel any in-flight LLM stream before swapping LLM clients.\n // Use resetEngine() (NOT reset()) so session transcript is preserved mid-meeting.\n // initializeLLMs() now also calls engine.reset() internally for double-safety.\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Gemini API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenaiApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setOpenaiApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-claude-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setClaudeApiKey(apiKey);\n\n // Also update the LLMHelper immediately\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setClaudeApiKey(apiKey);\n\n // CQ-06 fix: cancel in-flight stream before re-init (engine only, not session)\n appState.getIntelligenceManager().resetEngine();\n // Re-init IntelligenceManager\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Claude API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-natively-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const prevSttProvider = cm.getSttProvider();\n cm.setNativelyApiKey(apiKey);\n\n // Update LLMHelper immediately (same pattern as other provider keys)\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setNativelyKey(apiKey || null);\n\n // Sync the model into LLMHelper and notify the UI whenever the effective default changed\n const defaultModel = cm.getDefaultModel();\n const providers = [...(cm.getCurlProviders() || []), ...(cm.getCustomProviders() || [])];\n llmHelper.setModel(defaultModel, providers);\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) win.webContents.send('model-changed', defaultModel);\n });\n\n // If setNativelyApiKey auto-promoted the STT provider to 'natively', reconfigure\n // the audio pipeline immediately — without this, the in-memory pipeline still uses\n // the old STT provider (e.g. Google) until the app restarts.\n const newSttProvider = cm.getSttProvider();\n if (newSttProvider !== prevSttProvider) {\n console.log(`[IPC] set-natively-api-key: STT provider changed ${prevSttProvider} → ${newSttProvider}, reconfiguring pipeline`);\n await appState.reconfigureSttProvider();\n }\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Natively API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-natively-usage\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const key = CredentialsManager.getInstance().getNativelyApiKey();\n if (!key) return { ok: false, error: 'no_key' };\n\n const res = await fetch('https://api.natively.software/v1/usage', {\n headers: { 'x-natively-key': key },\n signal: AbortSignal.timeout(8000),\n });\n if (!res.ok) {\n const body = await res.json().catch(() => ({})) as any;\n return { ok: false, error: body.error || 'request_failed', status: res.status };\n }\n const data = await res.json() as any;\n return { ok: true, ...data };\n } catch (error: any) {\n return { ok: false, error: error.message || 'network_error' };\n }\n });\n\n // Custom Provider Handlers\n safeHandle(\"get-custom-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // Merge new Curl Providers with legacy Custom Providers\n // New ones take precedence if IDs conflict (though unlikely as UUIDs)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n return [...curlProviders, ...legacyProviders];\n } catch (error: any) {\n console.error(\"Error getting custom providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-custom-provider\", async (_, provider: unknown) => {\n try {\n // SECURITY FIX (P1-2): Validate provider payload shape before persisting.\n // Prevents malformed/malicious renderer data from polluting CredentialsManager.\n if (\n typeof provider !== 'object' || provider === null ||\n typeof (provider as any).id !== 'string' ||\n typeof (provider as any).name !== 'string' ||\n typeof (provider as any).curlCommand !== 'string'\n ) {\n console.error('[IPC] save-custom-provider: invalid payload shape', typeof provider);\n return { success: false, error: 'Invalid provider payload' };\n }\n\n const curlCmd: string = (provider as any).curlCommand;\n // Require {{TEXT}} so the app always has a defined injection point for the user prompt.\n // We do NOT require the string to start with 'curl' — curlCommand is a template field,\n // not necessarily a raw CLI string, and over-constraining it would break valid providers.\n if (!curlCmd.includes('{{TEXT}}')) {\n return { success: false, error: 'curlCommand must contain {{TEXT}} placeholder for the prompt' };\n }\n\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Save as CurlProvider (supports responsePath)\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-custom-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n // Try deleting from both storages to be safe\n CredentialsManager.getInstance().deleteCurlProvider(id);\n CredentialsManager.getInstance().deleteCustomProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-custom-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n // BUG-05 fix: providers may be in either the curl or legacy custom store —\n // merge both when looking up by id so neither store is silently ignored.\n const provider = [\n ...(cm.getCurlProviders() || []),\n ...(cm.getCustomProviders() || [])\n ].find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCustom(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to custom provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n\n // cURL Provider Handlers\n safeHandle(\"get-curl-providers\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getCurlProviders();\n } catch (error: any) {\n console.error(\"Error getting curl providers:\", error);\n return [];\n }\n });\n\n safeHandle(\"save-curl-provider\", async (_, provider: any) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().saveCurlProvider(provider);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"delete-curl-provider\", async (_, id: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().deleteCurlProvider(id);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error deleting curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"switch-to-curl-provider\", async (_, providerId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const provider = CredentialsManager.getInstance().getCurlProviders().find((p: any) => p.id === providerId);\n\n if (!provider) {\n throw new Error(\"Provider not found\");\n }\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n await llmHelper.switchToCurl(provider);\n\n // Re-init IntelligenceManager (optional, but good for consistency)\n appState.getIntelligenceManager().initializeLLMs();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error switching to curl provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Get stored API keys (masked for UI display)\n safeHandle(\"get-stored-credentials\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance().getAllCredentials();\n\n // Return masked versions for security (just indicate if set)\n const hasKey = (key?: string) => !!(key && key.trim().length > 0);\n\n return {\n hasGeminiKey: hasKey(creds.geminiApiKey),\n hasGroqKey: hasKey(creds.groqApiKey),\n hasOpenaiKey: hasKey(creds.openaiApiKey),\n hasClaudeKey: hasKey(creds.claudeApiKey),\n hasNativelyKey: hasKey(creds.nativelyApiKey),\n googleServiceAccountPath: creds.googleServiceAccountPath || null,\n sttProvider: creds.sttProvider || 'google',\n groqSttModel: creds.groqSttModel || 'whisper-large-v3-turbo',\n hasSttGroqKey: hasKey(creds.groqSttApiKey),\n hasSttOpenaiKey: hasKey(creds.openAiSttApiKey),\n hasDeepgramKey: hasKey(creds.deepgramApiKey),\n hasElevenLabsKey: hasKey(creds.elevenLabsApiKey),\n hasAzureKey: hasKey(creds.azureApiKey),\n azureRegion: creds.azureRegion || 'eastus',\n hasIbmWatsonKey: hasKey(creds.ibmWatsonApiKey),\n ibmWatsonRegion: creds.ibmWatsonRegion || 'us-south',\n hasSonioxKey: hasKey(creds.sonioxApiKey),\n // STT key values — returned so the settings UI can pre-populate input fields.\n // AI model keys (Gemini/Groq/OpenAI/Claude) remain boolean-only; STT keys are\n // surfaced here because users need to see which key is active when switching providers.\n sttGroqKey: creds.groqSttApiKey || '',\n sttOpenaiKey: creds.openAiSttApiKey || '',\n sttDeepgramKey: creds.deepgramApiKey || '',\n sttElevenLabsKey: creds.elevenLabsApiKey || '',\n sttAzureKey: creds.azureApiKey || '',\n sttIbmKey: creds.ibmWatsonApiKey || '',\n sttSonioxKey: creds.sonioxApiKey || '',\n hasTavilyKey: hasKey(creds.tavilyApiKey),\n // Dynamic Model Discovery - preferred models\n geminiPreferredModel: creds.geminiPreferredModel || undefined,\n groqPreferredModel: creds.groqPreferredModel || undefined,\n openaiPreferredModel: creds.openaiPreferredModel || undefined,\n claudePreferredModel: creds.claudePreferredModel || undefined,\n };\n } catch (error: any) {\n return { hasGeminiKey: false, hasGroqKey: false, hasOpenaiKey: false, hasClaudeKey: false, hasNativelyKey: false, googleServiceAccountPath: null, sttProvider: 'google', groqSttModel: 'whisper-large-v3-turbo', hasSttGroqKey: false, hasSttOpenaiKey: false, hasDeepgramKey: false, hasElevenLabsKey: false, hasAzureKey: false, azureRegion: 'eastus', hasIbmWatsonKey: false, ibmWatsonRegion: 'us-south', hasSonioxKey: false, hasTavilyKey: false, sttGroqKey: '', sttOpenaiKey: '', sttDeepgramKey: '', sttElevenLabsKey: '', sttAzureKey: '', sttIbmKey: '', sttSonioxKey: '' };\n }\n });\n\n // ==========================================\n // Dynamic Model Discovery Handlers\n // ==========================================\n\n safeHandle(\"fetch-provider-models\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey: string) => {\n try {\n // Fall back to stored key if no key was explicitly provided\n let key = apiKey?.trim();\n if (!key) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n if (provider === 'gemini') key = cm.getGeminiApiKey();\n else if (provider === 'groq') key = cm.getGroqApiKey();\n else if (provider === 'openai') key = cm.getOpenaiApiKey();\n else if (provider === 'claude') key = cm.getClaudeApiKey();\n }\n\n if (!key) {\n return { success: false, error: 'No API key available. Please save a key first.' };\n }\n\n const { fetchProviderModels } = require('./utils/modelFetcher');\n const models = await fetchProviderModels(provider, key);\n return { success: true, models };\n } catch (error: any) {\n console.error(`[IPC] Failed to fetch ${provider} models:`, error);\n const msg = error?.response?.data?.error?.message || error.message || 'Failed to fetch models';\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"set-provider-preferred-model\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setPreferredModel(provider, modelId);\n } catch (error: any) {\n console.error(`[IPC] Failed to set preferred model for ${provider}:`, error);\n }\n });\n\n // ==========================================\n // STT Provider Management Handlers\n // ==========================================\n\n safeHandle(\"set-stt-provider\", async (_, provider: 'google' | 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox' | 'natively') => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSttProvider(provider);\n\n // Reconfigure the audio pipeline to use the new STT provider\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting STT provider:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-stt-provider\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n return CredentialsManager.getInstance().getSttProvider();\n } catch (error: any) {\n return 'google';\n }\n });\n\n safeHandle(\"set-groq-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Groq STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-openai-stt-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setOpenAiSttApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving OpenAI STT API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-deepgram-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setDeepgramApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Deepgram API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-groq-stt-model\", async (_, model: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGroqSttModel(model);\n\n // Reconfigure the audio pipeline to use the new model\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Groq STT model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-elevenlabs-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setElevenLabsApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving ElevenLabs API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Azure API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-azure-region\", async (_, region: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setAzureRegion(region);\n\n // Reconfigure the pipeline since region changes the endpoint URL\n await appState.reconfigureSttProvider();\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting Azure region:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-ibmwatson-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setIbmWatsonApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving IBM Watson API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-soniox-api-key\", async (_, apiKey: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setSonioxApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error saving Soniox API key:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Helper to sanitize error messages (remove API key references)\n const sanitizeErrorMessage = (msg: string): string => {\n // Remove patterns like \": sk-***...***\" or \": sdasdada***...dwwC\"\n return msg.replace(/:\\s*[a-zA-Z0-9*]+\\*+[a-zA-Z0-9*]+\\.?$/g, '').trim();\n };\n\n safeHandle(\"test-stt-connection\", async (_, provider: 'groq' | 'openai' | 'deepgram' | 'elevenlabs' | 'azure' | 'ibmwatson' | 'soniox', apiKey: string, region?: string) => {\n console.log(`[IPC] Received test - stt - connection request for provider: ${provider} `);\n try {\n if (provider === 'deepgram') {\n // Test Deepgram via WebSocket connection\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const url = 'wss://api.deepgram.com/v1/listen?model=nova-3&encoding=linear16&sample_rate=16000&channels=1';\n const ws = new WebSocket(url, {\n headers: { Authorization: `Token ${apiKey} ` },\n });\n\n const timeout = setTimeout(() => {\n ws.close();\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n clearTimeout(timeout);\n try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch { }\n ws.close();\n resolve({ success: true });\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n if (provider === 'soniox') {\n // Test Soniox via WebSocket connection\n const WebSocket = require('ws');\n return await new Promise<{ success: boolean; error?: string }>((resolve) => {\n const ws = new WebSocket('wss://stt-rt.soniox.com/transcribe-websocket');\n\n const timeout = setTimeout(() => {\n ws.close();\n resolve({ success: false, error: 'Connection timed out' });\n }, 15000);\n\n ws.on('open', () => {\n // Send a minimal config to validate the API key\n ws.send(JSON.stringify({\n api_key: apiKey,\n model: 'stt-rt-v4',\n audio_format: 'pcm_s16le',\n sample_rate: 16000,\n num_channels: 1,\n }));\n });\n\n ws.on('message', (msg: any) => {\n clearTimeout(timeout);\n try {\n const res = JSON.parse(msg.toString());\n if (res.error_code) {\n resolve({ success: false, error: `${res.error_code}: ${res.error_message}` });\n } else {\n resolve({ success: true });\n }\n } catch {\n resolve({ success: true });\n }\n ws.close();\n });\n\n ws.on('error', (err: any) => {\n clearTimeout(timeout);\n resolve({ success: false, error: err.message || 'Connection failed' });\n });\n });\n }\n\n const axios = require('axios');\n const FormData = require('form-data');\n\n // Generate a tiny silent WAV (0.5s of silence at 16kHz mono 16-bit)\n const numSamples = 8000;\n const pcmData = Buffer.alloc(numSamples * 2);\n const wavHeader = Buffer.alloc(44);\n wavHeader.write('RIFF', 0);\n wavHeader.writeUInt32LE(36 + pcmData.length, 4);\n wavHeader.write('WAVE', 8);\n wavHeader.write('fmt ', 12);\n wavHeader.writeUInt32LE(16, 16);\n wavHeader.writeUInt16LE(1, 20);\n wavHeader.writeUInt16LE(1, 22);\n wavHeader.writeUInt32LE(16000, 24);\n wavHeader.writeUInt32LE(32000, 28);\n wavHeader.writeUInt16LE(2, 32);\n wavHeader.writeUInt16LE(16, 34);\n wavHeader.write('data', 36);\n wavHeader.writeUInt32LE(pcmData.length, 40);\n const testWav = Buffer.concat([wavHeader, pcmData]);\n\n if (provider === 'elevenlabs') {\n // ElevenLabs: Use /v1/voices to validate the API key (minimal scope required).\n // Scoped keys may lack speech_to_text or user_read but still be usable once permissions are added.\n try {\n await axios.get('https://api.elevenlabs.io/v1/voices', {\n headers: { 'xi-api-key': apiKey },\n timeout: 10000,\n });\n } catch (elErr: any) {\n const elStatus = elErr?.response?.data?.detail?.status;\n // If the error is \"invalid_api_key\", the key itself is wrong — fail.\n // Any other error (missing permission, etc.) means the key IS valid, just possibly scoped.\n if (elStatus === 'invalid_api_key') {\n throw elErr;\n }\n // Key is valid but scoped — pass with a warning\n console.log('[IPC] ElevenLabs key is valid but may have restricted scopes. Saving key.');\n }\n } else if (provider === 'azure') {\n // Azure: raw binary with subscription key\n const azureRegion = region || 'eastus';\n await axios.post(\n `https://${azureRegion}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US`,\n testWav,\n {\n headers: { 'Ocp-Apim-Subscription-Key': apiKey, 'Content-Type': 'audio/wav' },\n timeout: 15000,\n }\n );\n } else if (provider === 'ibmwatson') {\n // IBM Watson: raw binary with Basic auth\n const ibmRegion = region || 'us-south';\n await axios.post(\n `https://api.${ibmRegion}.speech-to-text.watson.cloud.ibm.com/v1/recognize`,\n testWav,\n {\n headers: {\n Authorization: `Basic ${Buffer.from(`apikey:${apiKey}`).toString('base64')}`,\n 'Content-Type': 'audio/wav',\n },\n timeout: 15000,\n }\n );\n } else {\n // Groq / OpenAI: multipart FormData\n const endpoint = provider === 'groq'\n ? 'https://api.groq.com/openai/v1/audio/transcriptions'\n : 'https://api.openai.com/v1/audio/transcriptions';\n const model = provider === 'groq' ? 'whisper-large-v3-turbo' : 'whisper-1';\n\n const form = new FormData();\n form.append('file', testWav, { filename: 'test.wav', contentType: 'audio/wav' });\n form.append('model', model);\n\n await axios.post(endpoint, form, {\n headers: {\n Authorization: `Bearer ${apiKey}`,\n ...form.getHeaders(),\n },\n timeout: 15000,\n });\n }\n\n return { success: true };\n } catch (error: any) {\n const respData = error?.response?.data;\n const rawMsg = respData?.error?.message || respData?.detail?.message || respData?.message || error.message || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n console.error(\"STT connection test failed:\", msg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"test-llm-connection\", async (_, provider: 'gemini' | 'groq' | 'openai' | 'claude', apiKey?: string) => {\n console.log(`[IPC] Received test-llm-connection request for provider: ${provider}`);\n try {\n if (!apiKey || !apiKey.trim()) {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const creds = CredentialsManager.getInstance();\n if (provider === 'gemini') apiKey = creds.getGeminiApiKey();\n else if (provider === 'groq') apiKey = creds.getGroqApiKey();\n else if (provider === 'openai') apiKey = creds.getOpenaiApiKey();\n else if (provider === 'claude') apiKey = creds.getClaudeApiKey();\n }\n\n if (!apiKey || !apiKey.trim()) {\n return { success: false, error: 'No API key provided' };\n }\n\n const axios = require('axios');\n let response;\n\n if (provider === 'gemini') {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-preview:generateContent`;\n response = await axios.post(url, {\n contents: [{ parts: [{ text: \"Hello\" }] }]\n }, {\n headers: { 'x-goog-api-key': apiKey },\n timeout: 15000\n });\n } else if (provider === 'groq') {\n response = await axios.post('https://api.groq.com/openai/v1/chat/completions', {\n model: \"llama-3.3-70b-versatile\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'openai') {\n response = await axios.post('https://api.openai.com/v1/chat/completions', {\n model: \"gpt-4o-mini\",\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: { Authorization: `Bearer ${apiKey}` },\n timeout: 15000\n });\n } else if (provider === 'claude') {\n response = await axios.post('https://api.anthropic.com/v1/messages', {\n model: \"claude-sonnet-4-6\",\n max_tokens: 10,\n messages: [{ role: \"user\", content: \"Hello\" }]\n }, {\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n timeout: 15000\n });\n }\n\n if (response && (response.status === 200 || response.status === 201)) {\n return { success: true };\n } else {\n return { success: false, error: 'Request failed with status ' + response?.status };\n }\n\n } catch (error: any) {\n console.error(\"LLM connection test failed:\", error);\n const rawMsg = error?.response?.data?.error?.message || error?.response?.data?.message || (error.response?.data?.error?.type ? `${error.response.data.error.type}: ${error.response.data.error.message}` : error.message) || 'Connection failed';\n const msg = sanitizeErrorMessage(rawMsg);\n return { success: false, error: msg };\n }\n });\n\n safeHandle(\"get-groq-fast-text-mode\", () => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n return { enabled: llmHelper.getGroqFastTextMode() };\n } catch (error: any) {\n return { enabled: false };\n }\n });\n\n // Set Groq Fast Text Mode\n safeHandle(\"set-groq-fast-text-mode\", (_, enabled: boolean) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n llmHelper.setGroqFastTextMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('groqFastTextMode', enabled);\n\n // Broadcast to all windows\n BrowserWindow.getAllWindows().forEach(win => {\n win.webContents.send('groq-fast-text-changed', enabled);\n });\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"set-model\", async (_, modelId: string) => {\n try {\n const llmHelper = appState.processingHelper.getLLMHelper();\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n\n // Get all providers (Curl + Custom)\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector (session-only update)\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Persist default model (from Settings) + update runtime + broadcast to all windows\n safeHandle(\"set-default-model\", async (_, modelId: string) => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n cm.setDefaultModel(modelId);\n\n // Also update the runtime model\n const llmHelper = appState.processingHelper.getLLMHelper();\n const curlProviders = cm.getCurlProviders();\n const legacyProviders = cm.getCustomProviders() || [];\n const allProviders = [...curlProviders, ...legacyProviders];\n llmHelper.setModel(modelId, allProviders);\n\n // Close the selector window if open\n appState.modelSelectorWindowHelper.hideWindow();\n\n // Broadcast to all windows so NativelyInterface can update its selector\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('model-changed', modelId);\n }\n });\n\n return { success: true };\n } catch (error: any) {\n console.error(\"Error setting default model:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // Read the persisted default model\n safeHandle(\"get-default-model\", async () => {\n try {\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n return { model: cm.getDefaultModel() };\n } catch (error: any) {\n console.error(\"Error getting default model:\", error);\n return { model: 'gemini-3.1-flash-lite-preview' };\n }\n });\n\n // --- Model Selector Window IPC ---\n\n safeHandle(\"show-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.showWindow(coords.x, coords.y);\n });\n\n safeHandle(\"hide-model-selector\", () => {\n appState.modelSelectorWindowHelper.hideWindow();\n });\n\n safeHandle(\"toggle-model-selector\", (_, coords: { x: number; y: number }) => {\n appState.modelSelectorWindowHelper.toggleWindow(coords.x, coords.y);\n });\n\n\n\n // Native Audio Service Handlers\n // Native Audio handlers removed as part of migration to driverless architecture\n safeHandle(\"native-audio-status\", async () => {\n // Always return true or pseudo-status since it's \"driverless\"\n return { connected: true };\n });\n\n safeHandle(\"get-input-devices\", async () => {\n return AudioDevices.getInputDevices();\n });\n\n safeHandle(\"get-output-devices\", async () => {\n return AudioDevices.getOutputDevices();\n });\n\n safeHandle(\"start-audio-test\", async (event, deviceId?: string) => {\n await appState.startAudioTest(deviceId);\n return { success: true };\n });\n\n safeHandle(\"stop-audio-test\", async () => {\n appState.stopAudioTest();\n return { success: true };\n });\n\n safeHandle(\"set-recognition-language\", async (_, key: string) => {\n appState.setRecognitionLanguage(key);\n return { success: true };\n });\n\n // ==========================================\n // Meeting Lifecycle Handlers\n // ==========================================\n\n safeHandle(\"start-meeting\", async (event, metadata?: any) => {\n try {\n await appState.startMeeting(metadata);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error starting meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"end-meeting\", async () => {\n try {\n await appState.endMeeting();\n return { success: true };\n } catch (error: any) {\n console.error(\"Error ending meeting:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"get-recent-meetings\", async () => {\n // Fetch from SQLite (limit 50)\n return DatabaseManager.getInstance().getRecentMeetings(50);\n });\n\n safeHandle(\"get-meeting-details\", async (event, id) => {\n // Helper to fetch full details\n return DatabaseManager.getInstance().getMeetingDetails(id);\n });\n\n safeHandle(\"update-meeting-title\", async (_, { id, title }: { id: string; title: string }) => {\n return DatabaseManager.getInstance().updateMeetingTitle(id, title);\n });\n\n safeHandle(\"update-meeting-summary\", async (_, { id, updates }: { id: string; updates: any }) => {\n return DatabaseManager.getInstance().updateMeetingSummary(id, updates);\n });\n\n safeHandle(\"seed-demo\", async () => {\n DatabaseManager.getInstance().seedDemoMeeting();\n\n // Ensure RAG embeddings exist for the demo meeting.\n // Use ensureDemoMeetingProcessed so we skip if already embedded\n // (avoids re-clearing 14 queue items on every app launch once processed).\n const ragManager = appState.getRAGManager();\n if (ragManager && ragManager.isReady()) {\n ragManager.ensureDemoMeetingProcessed().catch(console.error);\n }\n\n return { success: true };\n });\n\n safeHandle(\"flush-database\", async () => {\n const result = DatabaseManager.getInstance().clearAllData();\n return { success: result };\n });\n\n safeHandle(\"open-external\", async (event, url: string) => {\n try {\n const parsed = new URL(url);\n if (['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {\n await shell.openExternal(url);\n } else {\n console.warn(`[IPC] Blocked potentially unsafe open-external: ${url}`);\n }\n } catch {\n console.warn(`[IPC] Invalid URL in open-external: ${url}`);\n }\n });\n\n // ==========================================\n // Intelligence Mode Handlers\n // ==========================================\n\n // MODE 1: Assist (Passive observation)\n safeHandle(\"generate-assist\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const insight = await intelligenceManager.runAssistMode();\n return { insight };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 2: What Should I Say (Primary auto-answer)\n safeHandle(\"generate-what-to-say\", async (_, question?: string, imagePaths?: string[]) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n // Question and imagePaths are now optional - IntelligenceManager infers from transcript\n const answer = await intelligenceManager.runWhatShouldISay(question, 0.8, imagePaths);\n return { answer, question: question || 'inferred from context' };\n } catch (error: any) {\n // Return graceful fallback instead of throwing\n return {\n question: question || 'unknown'\n };\n }\n });\n\n safeHandle(\"generate-clarify\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const clarification = await intelligenceManager.runClarify();\n // If null returned without throwing, the engine already set mode to idle.\n // We must still ensure the frontend un-sticks — emit an error so onIntelligenceError fires.\n if (clarification === null) {\n const win = appState.getMainWindow();\n win?.webContents.send('intelligence-error', { error: 'Could not generate a clarifying question. Try again after some audio context is available.', mode: 'clarify' });\n }\n return { clarification };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-code-hint\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-code-hint: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const hint = await intelligenceManager.runCodeHint(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { hint };\n } catch (error: any) {\n throw error;\n }\n });\n\n safeHandle(\"generate-brainstorm\", async (_, imagePaths?: string[], problemStatement?: string) => {\n try {\n // If no explicit images were passed from the frontend, fall back to the\n // screenshot queue so the AI can always \"see\" the user's screen.\n const resolvedImagePaths: string[] =\n imagePaths && imagePaths.length > 0\n ? imagePaths\n : appState.getScreenshotQueue();\n\n console.log(`[IPC] generate-brainstorm: using ${resolvedImagePaths.length} image(s) (${imagePaths?.length ? 'explicit' : 'queue fallback'})`);\n\n const intelligenceManager = appState.getIntelligenceManager();\n const script = await intelligenceManager.runBrainstorm(\n resolvedImagePaths.length > 0 ? resolvedImagePaths : undefined,\n problemStatement\n );\n return { script };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Dynamic Action Button Mode (Recap vs Brainstorm)\n safeHandle(\"get-action-button-mode\", () => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n return sm.get('actionButtonMode') ?? 'recap';\n });\n\n safeHandle(\"set-action-button-mode\", (_, mode: 'recap' | 'brainstorm') => {\n const { SettingsManager } = require('./services/SettingsManager');\n const sm = SettingsManager.getInstance();\n sm.set('actionButtonMode', mode);\n\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('action-button-mode-changed', mode);\n }\n });\n\n return { success: true };\n });\n\n // MODE 3: Follow-Up (Refinement)\n safeHandle(\"generate-follow-up\", async (_, intent: string, userRequest?: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const refined = await intelligenceManager.runFollowUp(intent, userRequest);\n return { refined, intent };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 4: Recap (Summary)\n safeHandle(\"generate-recap\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const summary = await intelligenceManager.runRecap();\n return { summary };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 6: Follow-Up Questions\n safeHandle(\"generate-follow-up-questions\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const questions = await intelligenceManager.runFollowUpQuestions();\n return { questions };\n } catch (error: any) {\n throw error;\n }\n });\n\n // MODE 5: Manual Answer (Fallback)\n safeHandle(\"submit-manual-question\", async (_, question: string) => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n const answer = await intelligenceManager.runManualAnswer(question);\n return { answer, question };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Get current intelligence context\n safeHandle(\"get-intelligence-context\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n return {\n context: intelligenceManager.getFormattedContext(),\n lastAssistantMessage: intelligenceManager.getLastAssistantMessage(),\n activeMode: intelligenceManager.getActiveMode()\n };\n } catch (error: any) {\n throw error;\n }\n });\n\n // Reset intelligence state\n safeHandle(\"reset-intelligence\", async () => {\n try {\n const intelligenceManager = appState.getIntelligenceManager();\n intelligenceManager.reset();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n\n // Service Account Selection\n safeHandle(\"select-service-account\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [{ name: 'JSON', extensions: ['json'] }]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false, cancelled: true };\n }\n\n const filePath = result.filePaths[0];\n\n // Update backend state immediately\n appState.updateGoogleCredentials(filePath);\n\n // Persist the path for future sessions\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setGoogleServiceAccountPath(filePath);\n\n return { success: true, path: filePath };\n } catch (error: any) {\n console.error(\"Error selecting service account:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Theme System Handlers\n // ==========================================\n\n safeHandle(\"theme:get-mode\", () => {\n const tm = appState.getThemeManager();\n return {\n mode: tm.getMode(),\n resolved: tm.getResolvedTheme()\n };\n });\n\n safeHandle(\"theme:set-mode\", (_, mode: 'system' | 'light' | 'dark') => {\n appState.getThemeManager().setMode(mode);\n return { success: true };\n });\n\n // ==========================================\n // Calendar Integration Handlers\n // ==========================================\n\n safeHandle(\"calendar-connect\", async () => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().startAuthFlow();\n return { success: true };\n } catch (error: any) {\n console.error(\"Calendar auth error:\", error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"calendar-disconnect\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().disconnect();\n return { success: true };\n });\n\n safeHandle(\"get-calendar-status\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getConnectionStatus();\n });\n\n safeHandle(\"get-upcoming-events\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n return CalendarManager.getInstance().getUpcomingEvents();\n });\n\n safeHandle(\"calendar-refresh\", async () => {\n const { CalendarManager } = require('./services/CalendarManager');\n await CalendarManager.getInstance().refreshState();\n return { success: true };\n });\n\n // ==========================================\n // Follow-up Email Handlers\n // ==========================================\n\n safeHandle(\"generate-followup-email\", async (_, input: any) => {\n try {\n const { FOLLOWUP_EMAIL_PROMPT, GROQ_FOLLOWUP_EMAIL_PROMPT } = require('./llm/prompts');\n const { buildFollowUpEmailPromptInput } = require('./utils/emailUtils');\n\n const llmHelper = appState.processingHelper.getLLMHelper();\n\n // Build the context string from input\n const contextString = buildFollowUpEmailPromptInput(input);\n\n // Build prompts\n const geminiPrompt = `${FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n const groqPrompt = `${GROQ_FOLLOWUP_EMAIL_PROMPT}\\n\\nMEETING DETAILS:\\n${contextString}`;\n\n // Use chatWithGemini with alternateGroqMessage for fallback\n const emailBody = await llmHelper.chatWithGemini(geminiPrompt, undefined, undefined, true, groqPrompt);\n\n return emailBody;\n } catch (error: any) {\n console.error(\"Error generating follow-up email:\", error);\n throw error;\n }\n });\n\n safeHandle(\"extract-emails-from-transcript\", async (_, transcript: Array<{ text: string }>) => {\n try {\n const { extractEmailsFromTranscript } = require('./utils/emailUtils');\n return extractEmailsFromTranscript(transcript);\n } catch (error: any) {\n console.error(\"Error extracting emails:\", error);\n return [];\n }\n });\n\n safeHandle(\"get-calendar-attendees\", async (_, eventId: string) => {\n try {\n const { CalendarManager } = require('./services/CalendarManager');\n const cm = CalendarManager.getInstance();\n\n // Try to get attendees from the event\n const events = await cm.getUpcomingEvents();\n const event = events?.find((e: any) => e.id === eventId);\n\n if (event && event.attendees) {\n return event.attendees.map((a: any) => ({\n email: a.email,\n name: a.displayName || a.email?.split('@')[0] || ''\n })).filter((a: any) => a.email);\n }\n\n return [];\n } catch (error: any) {\n console.error(\"Error getting calendar attendees:\", error);\n return [];\n }\n });\n\n safeHandle(\"open-mailto\", async (_, { to, subject, body }: { to: string; subject: string; body: string }) => {\n try {\n const { buildMailtoLink } = require('./utils/emailUtils');\n const mailtoUrl = buildMailtoLink(to, subject, body);\n await shell.openExternal(mailtoUrl);\n return { success: true };\n } catch (error: any) {\n console.error(\"Error opening mailto:\", error);\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // RAG (Retrieval-Augmented Generation) Handlers\n // ==========================================\n\n // Store active query abort controllers for cancellation\n const activeRAGQueries = new Map<string, AbortController>();\n\n // Query meeting with RAG (meeting-scoped)\n safeHandle(\"rag:query-meeting\", async (event, { meetingId, query }: { meetingId: string; query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n // Fallback to regular chat if RAG not available\n console.log(\"[RAG] Not ready, falling back to regular chat\");\n return { fallback: true };\n }\n\n // For completed meetings, check if post-meeting RAG is processed.\n // For live meetings with JIT indexing, let RAGManager.queryMeeting() decide.\n if (!ragManager.isMeetingProcessed(meetingId) && !ragManager.isLiveIndexingActive(meetingId)) {\n console.log(`[RAG] Meeting ${meetingId} not processed and no JIT indexing, falling back to regular chat`);\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `meeting-${meetingId}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting(meetingId, query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { meetingId, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { meetingId });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If specific RAG failures, return fallback to use transcript window\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] Query failed with '${msg}', falling back to regular chat`);\n return { fallback: true };\n }\n\n console.error(\"[RAG] Query error:\", error);\n event.sender.send(\"rag:stream-error\", { meetingId, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query live meeting with JIT RAG\n safeHandle(\"rag:query-live\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n // Check if JIT indexing is active and has chunks\n if (!ragManager.isLiveIndexingActive('live-meeting-current')) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `live-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryMeeting('live-meeting-current', query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { live: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { live: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n const msg = error.message || \"\";\n // If JIT RAG failed (no embeddings yet, no relevant context), fallback to regular chat\n if (msg.includes('NO_RELEVANT_CONTEXT') || msg.includes('NO_MEETING_EMBEDDINGS')) {\n console.log(`[RAG] JIT query failed with '${msg}', falling back to regular live chat`);\n return { fallback: true };\n }\n console.error(\"[RAG] Live query error:\", error);\n event.sender.send(\"rag:stream-error\", { live: true, error: msg });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Query global (cross-meeting search)\n safeHandle(\"rag:query-global\", async (event, { query }: { query: string }) => {\n const ragManager = appState.getRAGManager();\n\n if (!ragManager || !ragManager.isReady()) {\n return { fallback: true };\n }\n\n const abortController = new AbortController();\n const queryKey = `global-${Date.now()}`;\n activeRAGQueries.set(queryKey, abortController);\n\n try {\n const stream = ragManager.queryGlobal(query, abortController.signal);\n\n for await (const chunk of stream) {\n if (abortController.signal.aborted) break;\n event.sender.send(\"rag:stream-chunk\", { global: true, chunk });\n }\n\n event.sender.send(\"rag:stream-complete\", { global: true });\n return { success: true };\n\n } catch (error: any) {\n if (error.name !== 'AbortError') {\n event.sender.send(\"rag:stream-error\", { global: true, error: error.message });\n }\n return { success: false, error: error.message };\n } finally {\n activeRAGQueries.delete(queryKey);\n }\n });\n\n // Cancel active RAG query\n safeHandle(\"rag:cancel-query\", async (_, { meetingId, global }: { meetingId?: string; global?: boolean }) => {\n const queryKey = global ? 'global' : `meeting-${meetingId}`;\n\n // Cancel any matching key\n for (const [key, controller] of activeRAGQueries) {\n if (key.startsWith(queryKey) || (global && key.startsWith('global'))) {\n controller.abort();\n activeRAGQueries.delete(key);\n }\n }\n\n return { success: true };\n });\n\n // Check if meeting has RAG embeddings\n safeHandle('rag:is-meeting-processed', async (_, meetingId: string) => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n return ragManager.isMeetingProcessed(meetingId);\n } catch (error: any) {\n console.error('[IPC rag:is-meeting-processed] Error:', error);\n return false;\n }\n });\n\n safeHandle('rag:reindex-incompatible-meetings', async () => {\n try {\n const ragManager = appState.getRAGManager();\n if (!ragManager) throw new Error('RAGManager not initialized');\n await ragManager.reindexIncompatibleMeetings();\n return { success: true };\n } catch (error: any) {\n console.error('[IPC rag:reindex-incompatible-meetings] Error:', error);\n return { success: false, error: error.message };\n }\n });\n\n // Get RAG queue status\n safeHandle(\"rag:get-queue-status\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { pending: 0, processing: 0, completed: 0, failed: 0 };\n return ragManager.getQueueStatus();\n });\n\n // Retry pending embeddings\n safeHandle(\"rag:retry-embeddings\", async () => {\n const ragManager = appState.getRAGManager();\n if (!ragManager) return { success: false };\n await ragManager.retryPendingEmbeddings();\n return { success: true };\n });\n\n // ==========================================\n // Profile Engine IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-resume\", async (_, filePath: string) => {\n try {\n // Premium gate: require active license for profile features\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (!LicenseManager.getInstance().isPremium()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-resume called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.RESUME);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-resume error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-status\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { hasProfile: false, profileMode: false };\n }\n // Map new KnowledgeStatus back to legacy UI shape temporarily\n const status = orchestrator.getStatus();\n return {\n hasProfile: status.hasResume,\n profileMode: status.activeMode,\n name: status.resumeSummary?.name,\n role: status.resumeSummary?.role,\n totalExperienceYears: status.resumeSummary?.totalExperienceYears\n };\n } catch (error: any) {\n return { hasProfile: false, profileMode: false };\n }\n });\n\n safeHandle(\"profile:set-mode\", async (_, enabled: boolean) => {\n try {\n // Premium gate: only allow enabling profile mode with active license\n if (enabled) {\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (!LicenseManager.getInstance().isPremium()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n orchestrator.setKnowledgeMode(enabled);\n\n const { SettingsManager } = require('./services/SettingsManager');\n SettingsManager.getInstance().set('knowledgeMode', enabled);\n\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.RESUME);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-profile\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return null;\n return orchestrator.getProfileData();\n } catch (error: any) {\n return null;\n }\n });\n\n safeHandle(\"profile:select-file\", async () => {\n try {\n const result: any = await dialog.showOpenDialog({\n properties: ['openFile'],\n filters: [\n { name: 'Resume Files', extensions: ['pdf', 'docx', 'txt'] }\n ]\n });\n\n if (result.canceled || result.filePaths.length === 0) {\n return { cancelled: true };\n }\n\n return { success: true, filePath: result.filePaths[0] };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // JD & Research IPC Handlers\n // ==========================================\n\n safeHandle(\"profile:upload-jd\", async (_, filePath: string) => {\n try {\n // Premium gate\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (!LicenseManager.getInstance().isPremium()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n console.log(`[IPC] profile:upload-jd called with: ${filePath}`);\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized. Please ensure API keys are configured.' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n const result = await orchestrator.ingestDocument(filePath, DocType.JD);\n return result;\n } catch (error: any) {\n console.error('[IPC] profile:upload-jd error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:delete-jd\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const { DocType } = require('../premium/electron/knowledge/types');\n orchestrator.deleteDocumentsByType(DocType.JD);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:research-company\", async (_, companyName: string) => {\n try {\n // Premium gate\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (!LicenseManager.getInstance().isPremium()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const engine = orchestrator.getCompanyResearchEngine();\n\n // Wire search provider: Tavily (user key) → Natively API (fallback) → none (LLM-only)\n const { CredentialsManager } = require('./services/CredentialsManager');\n const cm = CredentialsManager.getInstance();\n const tavilyApiKey = cm.getTavilyApiKey();\n if (tavilyApiKey) {\n const { TavilySearchProvider } = require('../premium/electron/knowledge/TavilySearchProvider');\n engine.setSearchProvider(new TavilySearchProvider(tavilyApiKey));\n } else {\n const nativelyKey = cm.getNativelyApiKey();\n if (nativelyKey) {\n const { NativelySearchProvider } = require('../premium/electron/knowledge/NativelySearchProvider');\n engine.setSearchProvider(new NativelySearchProvider(nativelyKey));\n console.log('[IPC] Company research: using Natively API search (no Tavily key configured)');\n }\n }\n\n // Build full JD context so the dossier is tailored to the exact role\n const profileData = orchestrator.getProfileData();\n const activeJD = profileData?.activeJD;\n const jdCtx = activeJD ? {\n title: activeJD.title,\n location: activeJD.location,\n level: activeJD.level,\n technologies: activeJD.technologies,\n requirements: activeJD.requirements,\n keywords: activeJD.keywords,\n compensation_hint: activeJD.compensation_hint,\n min_years_experience: activeJD.min_years_experience,\n } : {};\n const dossier = await engine.researchCompany(companyName, jdCtx, true);\n const searchQuotaExhausted = (engine.searchProvider as any)?.quotaExhausted === true;\n return { success: true, dossier, searchQuotaExhausted };\n } catch (error: any) {\n console.error('[IPC] profile:research-company error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:generate-negotiation\", async (_, force: boolean = false) => {\n try {\n // Premium gate\n const { LicenseManager } = require('../premium/electron/services/LicenseManager');\n if (!LicenseManager.getInstance().isPremium()) {\n return { success: false, error: 'Pro license required. Please activate a license key to use Profile Intelligence features.' };\n }\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) {\n return { success: false, error: 'Knowledge engine not initialized' };\n }\n const status = orchestrator.getStatus();\n if (!status.hasResume) {\n return { success: false, error: 'No resume loaded' };\n }\n\n // Use cache unless force-regenerating\n let script = force ? null : orchestrator.getNegotiationScript();\n if (!script) {\n script = await orchestrator.generateNegotiationScriptOnDemand();\n }\n if (!script) {\n return { success: false, error: 'Could not generate negotiation script. Ensure a resume and job description are uploaded.' };\n }\n return { success: true, script };\n } catch (error: any) {\n console.error('[IPC] profile:generate-negotiation error:', error);\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:get-negotiation-state\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false, error: 'Engine not ready' };\n const tracker = orchestrator.getNegotiationTracker();\n return {\n success: true,\n state: tracker.getState(),\n isActive: tracker.isActive(),\n };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n safeHandle(\"profile:reset-negotiation\", async () => {\n try {\n const orchestrator = appState.getKnowledgeOrchestrator();\n if (!orchestrator) return { success: false };\n orchestrator.resetNegotiationSession();\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Tavily Search API Credentials\n // ==========================================\n\n safeHandle(\"set-tavily-api-key\", async (_, apiKey: string) => {\n try {\n if (apiKey && !apiKey.startsWith('tvly-')) {\n return { success: false, error: 'Invalid Tavily API key. Keys must start with \"tvly-\".' };\n }\n const { CredentialsManager } = require('./services/CredentialsManager');\n CredentialsManager.getInstance().setTavilyApiKey(apiKey);\n return { success: true };\n } catch (error: any) {\n return { success: false, error: error.message };\n }\n });\n\n // ==========================================\n // Overlay Opacity (Stealth Mode)\n // ==========================================\n\n safeHandle(\"set-overlay-opacity\", async (_, opacity: number) => {\n // Clamp to valid range\n const clamped = Math.min(1.0, Math.max(0.35, opacity));\n // Broadcast to all renderer windows so the overlay picks it up in real-time\n BrowserWindow.getAllWindows().forEach(win => {\n if (!win.isDestroyed()) {\n win.webContents.send('overlay-opacity-changed', clamped);\n }\n });\n return;\n });\n}\n\n"}}}}