From 9ab79db31f82d825a80c5a5a2d3f5ee714ebdb5e Mon Sep 17 00:00:00 2001 From: Mohammad Askari Date: Mon, 15 Jun 2026 15:33:13 -0700 Subject: [PATCH 1/7] feat: add scoped chat MCP tool --- README.md | 41 +++++++ src/mcp-server-v3.js | 256 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) diff --git a/README.md b/README.md index 1b0be46..a07a06d 100644 --- a/README.md +++ b/README.md @@ -709,12 +709,53 @@ ws.onmessage = (e) => { ask_claudeQuery Claude (supports file upload) ask_geminiQuery Gemini (supports file upload) ask_perplexityQuery Perplexity (supports file upload) +ask_scoped_chatQuery an existing ChatGPT Project, Claude Project, Perplexity Space, or Gemini Gem using the logged-in browser session ask_all_aisSend same query to all providers at once ask_selectedPick specific providers to query compare_aisGet and compare responses side by side smart_queryAuto-picks best provider, falls back if one fails +#### Scoped chat examples + +Use ask_scoped_chat when you want a provider-specific workspace to apply its saved instructions, files, or knowledge. The tool requires an existing scope URL and sends through DOM mode so the logged-in browser page controls the workspace context. + +```json +{ + "provider": "chatgpt", + "scopeUrl": "https://chatgpt.com/g/g-p-example/project?tab=chats", + "message": "Use this project's instructions and summarize the attached packet.", + "files": ["/absolute/path/packet.zip"], + "newChat": true +} +``` + +```json +{ + "provider": "claude", + "scopeUrl": "https://claude.ai/project/example", + "message": "Continue from this project's knowledge base and draft the implementation checklist.", + "newChat": false +} +``` + +```json +{ + "provider": "perplexity", + "scopeUrl": "https://www.perplexity.ai/spaces/example", + "message": "Search this Space's sources and give me the relevant findings." +} +``` + +```json +{ + "provider": "gemini", + "scopeUrl": "https://gemini.google.com/gems/example", + "message": "Use this Gem's instructions to review the uploaded notes.", + "files": ["/absolute/path/notes.pdf"] +} +``` + ### 🔧 Development Tools diff --git a/src/mcp-server-v3.js b/src/mcp-server-v3.js index 30733f7..48b23a7 100644 --- a/src/mcp-server-v3.js +++ b/src/mcp-server-v3.js @@ -263,6 +263,140 @@ function buildMessageWithFiles(message, files) { return message; } +// ─── Scoped Chat Helpers ───────────────────────────── + +const SCOPED_CHAT_PROVIDERS = { + chatgpt: { + label: 'ChatGPT Project', + host: 'chatgpt.com', + scopeSegments: ['g'] + }, + claude: { + label: 'Claude Project', + host: 'claude.ai', + scopeSegments: ['project', 'projects'] + }, + perplexity: { + label: 'Perplexity Space', + host: 'perplexity.ai', + scopeSegments: ['space', 'spaces'] + }, + gemini: { + label: 'Gemini Gem', + host: 'gemini.google.com', + scopeSegments: ['gem', 'gems'] + } +}; + +function getScopedChatConfig(provider) { + const config = SCOPED_CHAT_PROVIDERS[provider]; + if (!config) { + throw new Error(`Unsupported scoped chat provider: ${provider}`); + } + return config; +} + +function hostMatches(actualHost, allowedHost) { + const host = actualHost.toLowerCase(); + const allowed = allowedHost.toLowerCase(); + return host === allowed || host.endsWith(`.${allowed}`); +} + +function parseProviderScopedUrl(provider, rawUrl) { + const config = getScopedChatConfig(provider); + let url; + + try { + url = new URL(rawUrl); + } catch { + throw new Error(`Invalid ${config.label} URL: ${rawUrl}`); + } + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`${config.label} URL must use http or https`); + } + + if (!hostMatches(url.hostname, config.host)) { + throw new Error(`${config.label} URL must be on ${config.host}`); + } + + const details = scopedDetailsForUrl(provider, url); + if (!details.identity) { + throw new Error(`${config.label} URL must point to an existing ${config.label.toLowerCase()}`); + } + + return { url, landingUrl: details.landingUrl, identity: details.identity, config }; +} + +function scopedIdentityForUrl(provider, urlLike) { + return scopedDetailsForUrl(provider, urlLike).identity; +} + +function scopedDetailsForUrl(provider, urlLike) { + let url; + try { + url = urlLike instanceof URL ? urlLike : new URL(urlLike); + } catch { + return { identity: '', landingUrl: null }; + } + + const config = SCOPED_CHAT_PROVIDERS[provider]; + if (!config || !hostMatches(url.hostname, config.host)) { + return { identity: '', landingUrl: null }; + } + + const segments = url.pathname.split('/').filter(Boolean); + for (const scopeSegment of config.scopeSegments) { + const index = segments.indexOf(scopeSegment); + if (index >= 0 && segments[index + 1]) { + const scopeId = segments[index + 1]; + const landingUrl = new URL(url.href); + landingUrl.hash = ''; + landingUrl.search = ''; + + if (provider === 'chatgpt' && scopeSegment === 'g') { + landingUrl.pathname = `/g/${scopeId}/project`; + landingUrl.search = '?tab=chats'; + } else { + landingUrl.pathname = `/${scopeSegment}/${scopeId}`; + } + + return { + identity: `${provider}:${config.host}/${scopeSegment}/${scopeId.toLowerCase()}`, + landingUrl + }; + } + } + + const gemId = url.searchParams.get('gem') || url.searchParams.get('gemId'); + if (provider === 'gemini' && gemId) { + return { + identity: `${provider}:${config.host}/gem/${gemId.toLowerCase()}`, + landingUrl: url + }; + } + + return { identity: '', landingUrl: null }; +} + +function resolveUploadFiles(files) { + if (!files || files.length === 0) return []; + + return files.map((filePath) => { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + throw new Error(`File not found: ${filePath}`); + } + + const stat = fs.statSync(resolved); + if (!stat.isFile()) { + throw new Error(`Not a file: ${filePath}`); + } + + return resolved; + }); +} + // ─── AI Providers (IPC-backed) ───────────────────── class AIProvider { @@ -347,6 +481,61 @@ class AIProvider { return result.response || 'No response received'; } + async _doScopedChat({ scopeUrl, message, files = [], newChat = true, uploadSettleMs = 3000 }) { + await this.ensureInitialized(); + + const scope = parseProviderScopedUrl(this.name, scopeUrl); + const uploadFiles = resolveUploadFiles(files); + let currentUrl = await this.currentUrl(); + const currentIdentity = scopedIdentityForUrl(this.name, currentUrl); + const shouldNavigate = newChat || currentIdentity !== scope.identity; + + if (shouldNavigate) { + console.error(`[${this.name}] Navigating to ${scope.config.label}: ${scope.landingUrl.href}`); + await this.ipc.send('navigate', this.name, { url: scope.landingUrl.href }); + await this.sleep(1500); + currentUrl = await this.currentUrl(); + } else { + console.error(`[${this.name}] Continuing current ${scope.config.label}`); + } + + const uploaded = []; + for (const filePath of uploadFiles) { + console.error(`[${this.name}] Uploading scoped chat file: ${filePath}`); + const uploadResult = await this.ipc.send('uploadFile', this.name, { filePath }); + uploaded.push({ filePath, result: uploadResult }); + await this.sleep(Math.max(0, uploadSettleMs)); + await this.ipc.send('waitForSendButton', this.name, {}); + } + + let typingCheck = await this.getTypingStatus(); + let waitCount = 0; + while (typingCheck.isTyping && waitCount < 5) { + console.error(`[${this.name}] AI still typing, waiting before scoped chat send...`); + await this.sleep(1000); + typingCheck = await this.getTypingStatus(); + waitCount++; + } + + console.error(`[${this.name}] Sending scoped chat message with DOM mode...`); + await this.ipc.send('sendMessage', this.name, { message, forceDOM: true }); + + console.error(`[${this.name}] Waiting for scoped chat response...`); + const result = await this.ipc.send('getResponseWithTyping', this.name, {}); + const response = result.response || 'No response received'; + + return { + scopedChat: true, + provider: this.name, + scope: scope.config.label, + scopeUrl: scope.landingUrl.href, + currentUrl: await this.currentUrl(), + newChat, + filesUploaded: uploaded.length, + response + }; + } + async chat(message, useCache = true) { // Cache check runs OUTSIDE queue — instant return, no waiting if (useCache && this.cache.has(message)) { @@ -381,7 +570,27 @@ class AIProvider { return responsePromise; } + async scopedChat(options) { + this._queueLength++; + const position = this._queueLength; + if (position > 1) { + console.error(`[${this.name}] Scoped chat queued (position ${position}). Waiting for previous request...`); + } + const responsePromise = this._queue.then(async () => { + console.error(`[${this.name}] Processing scoped chat (${position} of ${this._queueLength})...`); + const response = await this._doScopedChat(options); + this._queueLength--; + return response; + }).catch((err) => { + this._queueLength--; + throw err; + }); + + this._queue = responsePromise.catch(() => {}); + + return responsePromise; + } async search(query, useCache = true) { return await this.chat(query, useCache); @@ -392,6 +601,11 @@ class AIProvider { return result; } + async currentUrl() { + const result = await this.executeScript('window.location.href'); + return result?.result || ''; + } + async newConversation() { await this.ipc.send('newConversation', this.name); } @@ -463,6 +677,8 @@ const chatgpt = new AIProvider('chatgpt', ipcClient); const claude = new AIProvider('claude', ipcClient); const gemini = new AIProvider('gemini', ipcClient); +const providerInstances = { perplexity, chatgpt, claude, gemini }; + const router = new SmartRouter({ perplexity, chatgpt, claude, gemini }); // Create MCP Server @@ -510,6 +726,17 @@ function formatResult(obj) { return out.trim(); } + // ask_scoped_chat — { scopedChat, provider, scope, response } + if (obj.scopedChat && obj.response) { + let out = ''; + out += `**Provider:** ${obj.provider}\n`; + out += `**Scope:** ${obj.scope}\n`; + if (obj.currentUrl) out += `**Current URL:** ${obj.currentUrl}\n`; + if (typeof obj.filesUploaded === 'number') out += `**Files uploaded:** ${obj.filesUploaded}\n`; + out += `\n${obj.response}`; + return out.trim(); + } + // compare_ais / ask_all — { provider: response, ... } where values are strings if (Object.values(obj).every(v => typeof v === 'string')) { let out = ''; @@ -1311,6 +1538,35 @@ server.tool( } ); +server.tool( + 'ask_scoped_chat', + { + provider: z.enum(['chatgpt', 'claude', 'perplexity', 'gemini']).describe('Provider to use: chatgpt, claude, perplexity, or gemini'), + scopeUrl: z.string().describe('Existing project-like workspace URL: ChatGPT Project, Claude Project, Perplexity Space, or Gemini Gem'), + message: z.string().describe('Message to send inside the scoped chat using DOM mode'), + files: z.array(z.string()).optional().describe('Optional: local file paths to upload into the scoped chat before sending. These are uploaded, not pasted as text context.'), + newChat: z.boolean().optional().describe('Default true. When true, navigate to the scope URL before sending. When false, continue the current scoped chat if it matches the same scope.'), + uploadSettleMs: z.number().optional().describe('Milliseconds to wait after each upload before sending. Default: 3000.') + }, + async ({ provider, scopeUrl, message, files, newChat = true, uploadSettleMs = 3000 }) => { + const disabled = checkDisabled(provider); + if (disabled) return disabled; + + try { + const instance = providerInstances[provider]; + return toolResponse(await instance.scopedChat({ + scopeUrl, + message, + files, + newChat, + uploadSettleMs + })); + } catch (err) { + return toolError(err); + } + } +); + server.tool( 'new_conversation', {}, From 23a2f173e6aece58960d7d2308622fc64e89d720 Mon Sep 17 00:00:00 2001 From: Mohammad Askari Date: Fri, 19 Jun 2026 20:17:19 +0000 Subject: [PATCH 2/7] fix: address scoped chat review feedback - Canonicalize scope identity using the first configured segment so equivalent URLs (e.g. /project/ vs /projects/) resolve to the same identity; ask_scoped_chat(newChat=false) now reuses the current scope instead of re-navigating. - Only count successful file uploads. Check uploadResult.success, skip the post-upload settle/wait for failures, and surface failed uploads in the result instead of reporting every attempt as uploaded. - Return a canonical /gem/ landing URL for the Gemini gem-ID query form, clearing hash/query to match the segment-based branch behavior. --- src/mcp-server-v3.js | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/mcp-server-v3.js b/src/mcp-server-v3.js index 48b23a7..41d2c18 100644 --- a/src/mcp-server-v3.js +++ b/src/mcp-server-v3.js @@ -346,6 +346,9 @@ function scopedDetailsForUrl(provider, urlLike) { } const segments = url.pathname.split('/').filter(Boolean); + // Canonical segment so equivalent scope URLs (e.g. /project/ vs + // /projects/) resolve to the same identity for newChat=false reuse. + const canonicalSegment = config.scopeSegments[0]; for (const scopeSegment of config.scopeSegments) { const index = segments.indexOf(scopeSegment); if (index >= 0 && segments[index + 1]) { @@ -362,7 +365,7 @@ function scopedDetailsForUrl(provider, urlLike) { } return { - identity: `${provider}:${config.host}/${scopeSegment}/${scopeId.toLowerCase()}`, + identity: `${provider}:${config.host}/${canonicalSegment}/${scopeId.toLowerCase()}`, landingUrl }; } @@ -370,9 +373,15 @@ function scopedDetailsForUrl(provider, urlLike) { const gemId = url.searchParams.get('gem') || url.searchParams.get('gemId'); if (provider === 'gemini' && gemId) { + // Clear hash/query and use the canonical /gem/ path so the landing + // URL matches the segment-based branch instead of the raw input URL. + const landingUrl = new URL(url.href); + landingUrl.hash = ''; + landingUrl.search = ''; + landingUrl.pathname = `/gem/${gemId}`; return { identity: `${provider}:${config.host}/gem/${gemId.toLowerCase()}`, - landingUrl: url + landingUrl }; } @@ -500,12 +509,19 @@ class AIProvider { } const uploaded = []; + const failedUploads = []; for (const filePath of uploadFiles) { console.error(`[${this.name}] Uploading scoped chat file: ${filePath}`); const uploadResult = await this.ipc.send('uploadFile', this.name, { filePath }); - uploaded.push({ filePath, result: uploadResult }); - await this.sleep(Math.max(0, uploadSettleMs)); - await this.ipc.send('waitForSendButton', this.name, {}); + if (uploadResult && uploadResult.success) { + uploaded.push({ filePath, result: uploadResult }); + await this.sleep(Math.max(0, uploadSettleMs)); + await this.ipc.send('waitForSendButton', this.name, {}); + } else { + const reason = (uploadResult && uploadResult.error) || 'upload failed'; + console.error(`[${this.name}] Scoped chat file upload failed: ${filePath} — ${reason}`); + failedUploads.push({ filePath, error: reason }); + } } let typingCheck = await this.getTypingStatus(); @@ -532,6 +548,8 @@ class AIProvider { currentUrl: await this.currentUrl(), newChat, filesUploaded: uploaded.length, + filesFailed: failedUploads.length, + ...(failedUploads.length ? { uploadErrors: failedUploads.map(f => `${f.filePath}: ${f.error}`) } : {}), response }; } @@ -733,6 +751,10 @@ function formatResult(obj) { out += `**Scope:** ${obj.scope}\n`; if (obj.currentUrl) out += `**Current URL:** ${obj.currentUrl}\n`; if (typeof obj.filesUploaded === 'number') out += `**Files uploaded:** ${obj.filesUploaded}\n`; + if (obj.filesFailed) out += `**Files failed:** ${obj.filesFailed}\n`; + if (Array.isArray(obj.uploadErrors) && obj.uploadErrors.length) { + out += `**Upload errors:**\n${obj.uploadErrors.map(e => `- ${e}`).join('\n')}\n`; + } out += `\n${obj.response}`; return out.trim(); } From 1555ddad28a5e6628593cd42257408f3d253923c Mon Sep 17 00:00:00 2001 From: Mohammad Askari <39827036+mhmdaskari@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:28:40 -0700 Subject: [PATCH 3/7] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mcp-server-v3.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/mcp-server-v3.js b/src/mcp-server-v3.js index 41d2c18..2316dba 100644 --- a/src/mcp-server-v3.js +++ b/src/mcp-server-v3.js @@ -322,7 +322,7 @@ function parseProviderScopedUrl(provider, rawUrl) { const details = scopedDetailsForUrl(provider, url); if (!details.identity) { - throw new Error(`${config.label} URL must point to an existing ${config.label.toLowerCase()}`); + throw new Error(`${config.label} URL must be a valid ${config.label.toLowerCase()} workspace URL`); } return { url, landingUrl: details.landingUrl, identity: details.identity, config }; @@ -504,7 +504,10 @@ class AIProvider { await this.ipc.send('navigate', this.name, { url: scope.landingUrl.href }); await this.sleep(1500); currentUrl = await this.currentUrl(); - } else { + const navigatedIdentity = scopedIdentityForUrl(this.name, currentUrl); + if (navigatedIdentity !== scope.identity) { + throw new Error(`Failed to open ${scope.config.label}. Expected ${scope.landingUrl.href} but landed on ${currentUrl}`); + } console.error(`[${this.name}] Continuing current ${scope.config.label}`); } @@ -751,7 +754,7 @@ function formatResult(obj) { out += `**Scope:** ${obj.scope}\n`; if (obj.currentUrl) out += `**Current URL:** ${obj.currentUrl}\n`; if (typeof obj.filesUploaded === 'number') out += `**Files uploaded:** ${obj.filesUploaded}\n`; - if (obj.filesFailed) out += `**Files failed:** ${obj.filesFailed}\n`; + if (typeof obj.filesFailed === 'number') out += `**Files failed:** ${obj.filesFailed}\n`; if (Array.isArray(obj.uploadErrors) && obj.uploadErrors.length) { out += `**Upload errors:**\n${obj.uploadErrors.map(e => `- ${e}`).join('\n')}\n`; } From 90aa517a56a3f19d2fa8b42d31cadc8fa76e0bb8 Mon Sep 17 00:00:00 2001 From: Mohammad Askari <39827036+mhmdaskari@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:32:34 -0700 Subject: [PATCH 4/7] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mcp-server-v3.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/mcp-server-v3.js b/src/mcp-server-v3.js index 2316dba..3a9ec30 100644 --- a/src/mcp-server-v3.js +++ b/src/mcp-server-v3.js @@ -502,12 +502,23 @@ class AIProvider { if (shouldNavigate) { console.error(`[${this.name}] Navigating to ${scope.config.label}: ${scope.landingUrl.href}`); await this.ipc.send('navigate', this.name, { url: scope.landingUrl.href }); - await this.sleep(1500); - currentUrl = await this.currentUrl(); - const navigatedIdentity = scopedIdentityForUrl(this.name, currentUrl); + + const NAV_TIMEOUT_MS = 15000; + const POLL_MS = 500; + const start = Date.now(); + let navigatedIdentity = ''; + + while (Date.now() - start < NAV_TIMEOUT_MS) { + await this.sleep(POLL_MS); + currentUrl = await this.currentUrl(); + navigatedIdentity = scopedIdentityForUrl(this.name, currentUrl); + if (navigatedIdentity === scope.identity) break; + } + if (navigatedIdentity !== scope.identity) { throw new Error(`Failed to open ${scope.config.label}. Expected ${scope.landingUrl.href} but landed on ${currentUrl}`); } + } else { console.error(`[${this.name}] Continuing current ${scope.config.label}`); } @@ -1571,7 +1582,7 @@ server.tool( message: z.string().describe('Message to send inside the scoped chat using DOM mode'), files: z.array(z.string()).optional().describe('Optional: local file paths to upload into the scoped chat before sending. These are uploaded, not pasted as text context.'), newChat: z.boolean().optional().describe('Default true. When true, navigate to the scope URL before sending. When false, continue the current scoped chat if it matches the same scope.'), - uploadSettleMs: z.number().optional().describe('Milliseconds to wait after each upload before sending. Default: 3000.') + uploadSettleMs: z.number().int().min(0).optional().describe('Milliseconds to wait after each upload before sending. Default: 3000.') }, async ({ provider, scopeUrl, message, files, newChat = true, uploadSettleMs = 3000 }) => { const disabled = checkDisabled(provider); From b515be16ffc6e3454fd7d773b25cc43099d64dd1 Mon Sep 17 00:00:00 2001 From: Mohammad Askari <39827036+mhmdaskari@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:37:53 -0700 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mcp-server-v3.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mcp-server-v3.js b/src/mcp-server-v3.js index 3a9ec30..181254a 100644 --- a/src/mcp-server-v3.js +++ b/src/mcp-server-v3.js @@ -312,8 +312,8 @@ function parseProviderScopedUrl(provider, rawUrl) { throw new Error(`Invalid ${config.label} URL: ${rawUrl}`); } - if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error(`${config.label} URL must use http or https`); + if (url.protocol !== 'https:') { + throw new Error(`${config.label} URL must use https`); } if (!hostMatches(url.hostname, config.host)) { @@ -530,8 +530,8 @@ class AIProvider { if (uploadResult && uploadResult.success) { uploaded.push({ filePath, result: uploadResult }); await this.sleep(Math.max(0, uploadSettleMs)); - await this.ipc.send('waitForSendButton', this.name, {}); - } else { + const button = await this.ipc.send('waitForSendButton', this.name, {}); + if (!button?.ready) throw new Error(`Timed out waiting for send button after uploading: ${filePath}`); const reason = (uploadResult && uploadResult.error) || 'upload failed'; console.error(`[${this.name}] Scoped chat file upload failed: ${filePath} — ${reason}`); failedUploads.push({ filePath, error: reason }); From 145e5307137b0bbd54a2a90ad4e6d73a99f5cdc7 Mon Sep 17 00:00:00 2001 From: Mohammad Askari <39827036+mhmdaskari@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:40:45 -0700 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mcp-server-v3.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp-server-v3.js b/src/mcp-server-v3.js index 181254a..56d23cb 100644 --- a/src/mcp-server-v3.js +++ b/src/mcp-server-v3.js @@ -532,7 +532,8 @@ class AIProvider { await this.sleep(Math.max(0, uploadSettleMs)); const button = await this.ipc.send('waitForSendButton', this.name, {}); if (!button?.ready) throw new Error(`Timed out waiting for send button after uploading: ${filePath}`); - const reason = (uploadResult && uploadResult.error) || 'upload failed'; + } else { + const reason = uploadResult?.error || 'upload failed'; console.error(`[${this.name}] Scoped chat file upload failed: ${filePath} — ${reason}`); failedUploads.push({ filePath, error: reason }); } From eda7bf9be4c8cbd0a286c89729a3ed7b3f08cea2 Mon Sep 17 00:00:00 2001 From: Mohammad Askari Date: Fri, 19 Jun 2026 20:58:21 +0000 Subject: [PATCH 7/7] fix: clear stale API response cache on DOM-mode sends ask_scoped_chat sends with forceDOM=true and then reads the result via getResponseWithTyping, which prefers _apiResponseCache[provider] when present. The forceDOM branch of sendMessageToProvider never cleared that cache, so a stale API response left populated by a prior request (e.g. a REST queryProvider that used sendResult.result.response directly and skipped getResponseWithTyping) could be returned instead of the actual DOM response. Delete _apiResponseCache[provider] when forceDOM=true so DOM-mode sends are always answered by the live DOM response. This closes the only staleness vector: every non-DOM caller of getResponseWithTyping is already preceded by an API-first send that refreshes or clears the cache. --- electron/main-v2.cjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/electron/main-v2.cjs b/electron/main-v2.cjs index bbd0e9c..2738e58 100644 --- a/electron/main-v2.cjs +++ b/electron/main-v2.cjs @@ -754,6 +754,10 @@ async function sendMessageToProvider(provider, message, forceDOM = false) { } } else { console.log(`[${provider}] forceDOM=true — skipping API, typing into open conversation`); + // Drop any stale API response (e.g. left populated by a prior REST + // queryProvider that consumed sendResult.result.response directly) so + // getResponseWithTyping reads this DOM send instead of an old cache. + delete _apiResponseCache[provider]; } // DOM fallback: types into currently open conversation