diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..9f562bc --- /dev/null +++ b/NOTES.md @@ -0,0 +1,111 @@ +# Proxima — Setup, Architecture, Fix Log + +## What it is +Electron app that automates ChatGPT/Claude/Gemini/Perplexity web UIs via embedded BrowserViews. Exposes them through an MCP stdio server so Claude (or any MCP client) can ask any provider questions, generate images via DALL-E, etc. + +## Install paths +- **Source**: `C:\Tools\Proxima\` (main-v2.cjs, mcp-server-v3.js, etc.) +- **Packed app**: `C:\Tools\Proxima\dist\win-unpacked\Proxima.exe` +- **Packed ASAR**: `C:\Tools\Proxima\dist\win-unpacked\resources\app.asar` +- **Extracted ASAR (editable)**: `C:\Tools\Proxima\dist\win-unpacked\resources\app-extracted\` +- **ASAR backup**: `C:\Tools\Proxima\dist\win-unpacked\resources\app.asar.bak` +- **Debug log**: `C:\Users\Jakob\proxima-debug.log` (via `dlog()` in main-v2.cjs) +- **Image save dir**: `C:\Users\Jakob\Pictures\Proxima\` (auto-created) +- **MCP config**: `C:\Users\Jakob\.claude\.mcp.json` → spawns `node C:\Tools\Proxima\src\mcp-server-v3.js` +- **Enabled providers**: `C:\Tools\Proxima\src\enabled-providers.json` → currently `perplexity, chatgpt, gemini` + +## Key files +| File | Purpose | +|------|---------| +| `electron/main-v2.cjs` | Main Electron process. Browser automation, DOM extraction, IPC server on port 19222. | +| `src/mcp-server-v3.js` | MCP stdio server. Bridges Claude ↔ Electron IPC. ESM. | +| `electron/browser-manager.cjs` | BrowserView management per provider. | +| `electron/rest-api.cjs` | Optional REST endpoint. | + +## Architecture (v4.1.0 — API-first) +1. Claude calls MCP tool (e.g. `generate_image`) +2. `mcp-server-v3.js` opens TCP socket to localhost:19222, sends JSON request +3. `main-v2.cjs` (IPC server) routes request → calls `sendMessage` → `getResponseWithTypingStatus` +4. **API path (primary)**: `sendMessage` calls `providerAPI.sendViaAPI()` → injects `chatgpt-engine.js` into BrowserView → sends via `/backend-api/conversation` SSE → captures full response text → caches in `_apiResponseCache` +5. **`getResponseWithTypingStatus`**: checks `_apiResponseCache` first, returns immediately (no DOM scraping needed). Calls `postProcessImages()` on API response before returning. +6. **DOM fallback** (if API fails): polls assistant DOM elements for up to 100s, also calls `postProcessImages()` on result +7. `postProcessImages()`: parses `![alt](url)` → downloads via `electronNet.request` with session cookies → saves to `~/Pictures/Proxima/img_TIMESTAMP.ext` → returns Markdown with local file path + +## New in v4.1.0 +- `electron/providers/chatgpt-engine.js` — runs inside ChatGPT BrowserView, uses direct API calls (SSE), implements SHA3-512 POW solver for `sentinel/chat-requirements` +- `electron/providers/claude-engine.js`, `gemini-engine.js`, `perplexity-engine.js` — same pattern +- `electron/provider-api.cjs` — injects engines, exposes `sendViaAPI()` +- `electron/ws-server.cjs` — optional WebSocket server +- `cli/proxima-cli.cjs` — standalone CLI + +## Critical workflow: editing main-v2.cjs +**Source edits don't affect running app until repacked!** App runs from ASAR. + +```powershell +# 1. Edit C:\Tools\Proxima\electron\main-v2.cjs +# 2. Copy to extracted ASAR dir +Copy-Item "C:\Tools\Proxima\electron\main-v2.cjs" ` + "C:\Tools\Proxima\dist\win-unpacked\resources\app-extracted\electron\main-v2.cjs" -Force +# 3. Repack ASAR +cd "C:\Tools\Proxima\dist\win-unpacked\resources" +npx @electron/asar pack app-extracted app.asar +# 4. Restart Proxima +Get-Process proxima -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep 1.5 +Start-Process "C:\Tools\Proxima\dist\win-unpacked\Proxima.exe" +``` + +## Fix log (2026-05-25) + +### `generate_image` returned "No response captured" +**Root cause**: ChatGPT's new reasoning UI ("Thought for Xs") renders DALL-E image responses WITHOUT `[data-message-author-role="assistant"]` wrapper. Standard DOM query returned 0 messages. + +**Fix** in `main-v2.cjs` ChatGPT DOM extraction block (~line 1708): +- Added `dalleImgs` filter for known CDN domains (`oaidalleapiprodscus`, `openai`, `blob:`, `files.oaiusercontent`) +- Added `bigImgs` broader fallback: any HTTP img with `naturalWidth > 200`, excluding `avatar`/`logo` +- ChatGPT signed URLs use `chatgpt.com/backend-api/estuary/content?id=...&sig=...` — caught by `bigImgs` + +### Image URL not directly accessible +**Problem**: Signed ChatGPT URLs require session cookies. External MCP clients can't access them. + +**Fix**: Added `postProcessImages()` in main-v2.cjs that: +- Parses `![alt](url)` from response text +- Downloads each URL via `electron.net.request` with `useSessionCookies: true` (auto-attaches ChatGPT cookies) +- Saves to `~/Pictures/Proxima/img_TIMESTAMP.ext` (extension from Content-Type) +- Returns text with `![alt](file:///C:/Users/Jakob/Pictures/Proxima/img_...png)` + original URL kept as reference + +### Other earlier fixes (still active) +- `analyze_image_url` used `require()` in ESM → fixed via top-level imports of `https`, `http`, `os` in mcp-server-v3.js +- IPC socket close didn't reject pending requests → added rejection loop +- `getEnabledProviders()` read disk every call → 30s TTL cache +- 800ms sleep between `sendMessage` and `getResponseWithTyping` (race fix) +- IPC timeout 120s → 300s (image gen) +- Response desync (off-by-one): `__proxima_captured_response` retained previous response → clear buffer in `sendMessage` BEFORE sending +- ChatGPT fingerprint captured AFTER sendMessage → captured "Denke nach…" thinking bubble. Fixed: capture BEFORE send in sendMessage handler +- Added thinking-indicator blocklist (`THINKING_PATTERNS`) +- ChatGPT `MAX_POLLS` 40 → 200 (100s timeout) +- `img` tag support in `domToMarkdown()` + +## Verified working +- `ask_chatgpt("text")` → returns text correctly +- `generate_image({prompt})` → returns Markdown with local file path + original URL +- Image saved to `C:\Users\Jakob\Pictures\Proxima\` + +## Known remaining quirks +- **v4.1 API path**: ChatGPT engine POW solver may fail if ChatGPT changes challenge format → falls back to DOM +- Image gen response from SSE stream may not include inline image URL for all ChatGPT versions — if so, falls back to DOM `bigImgs` scraping +- `chatgpt-engine.js` doesn't handle WebSocket redirect (returns error "WebSocket mode not supported") — rare + +## Critical: `ws` module for dev build +v4.1 `ws-server.cjs` requires `ws` npm package. `app.asar.unpacked\node_modules` ships without it from the original ASAR. After manual repack, must copy manually: +```powershell +Copy-Item "C:\Tools\Proxima\node_modules\ws" ` + "C:\Tools\Proxima\dist\win-unpacked\resources\app-extracted\node_modules\ws" -Recurse -Force +``` +Then repack ASAR. Without this, Proxima starts but never binds port 19222 (silent crash: `rest-api.cjs` → `ws-server.cjs` → `require('ws')` → fail). + +## Merge notes (2026-05-25 — v3.0.0 → v4.1.0) +- Pulled upstream, stashed our changes, applied conflicts manually +- **Kept from our work**: `electronNet` import, `downloadImageWithSession()`, `postProcessImages()`, `dlog()`, `IMAGE_SAVE_DIR`, `chatgptOldImageUrls` fingerprint, `bigImgs` fallback, Perplexity stability fix (was in upstream) +- **Dropped from our work** (superseded by v4.1): network interceptor poll loop in `getProviderResponse` (now handled by API-first path), global fingerprint vars (now use `responseState`) +- All mcp-server-v3.js fixes already in upstream v4.1 diff --git a/README.md b/README.md index 1b0be46..a4cec13 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ # Proxima +> **This is a fork by [@usexless](https://github.com/usexless)** with the following improvements over the original [Zen4-bit/Proxima](https://github.com/Zen4-bit/Proxima): +> +> - **Image generation fixed** — ChatGPT reasoning UI (`Thought for X s`) previously returned "No response captured". Now waits for stop button to disappear before capturing, ensuring the final generated image is returned. +> - **Reference image support** (`reference_image` param) — upload a local image as style reference when generating. Fixed false-positive bug where the uploaded reference was mistakenly returned as the generated result. +> - **Improved `generate_image` MCP tool description** — AI clients now know to call `new_conversation` first, generate one image at a time, and how to structure prompts. + **4 AI providers. 1 local server. No API keys.** Use ChatGPT, Claude, Gemini & Perplexity directly inside your coding tools — through your existing accounts. diff --git a/electron/main-v2.cjs b/electron/main-v2.cjs index bbd0e9c..cf36b14 100644 --- a/electron/main-v2.cjs +++ b/electron/main-v2.cjs @@ -1,9 +1,17 @@ // Proxima main process — embedded browser + anti-detection + IPC server -const { app, BrowserWindow, ipcMain, shell, session, clipboard } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, session, net: electronNet, clipboard } = require('electron'); const path = require('path'); const fs = require('fs'); const net = require('net'); + +// --- Debug logging --- +const DEBUG_LOG = path.join(require('os').homedir(), 'proxima-debug.log'); +function dlog(msg) { + const line = `[${new Date().toISOString()}] ${msg}\n`; + try { fs.appendFileSync(DEBUG_LOG, line); } catch(e) {} + console.log(msg); +} const BrowserManager = require('./browser-manager.cjs'); const { initRestAPI, startRestAPI, stopRestAPI, isRestAPIRunning } = require('./rest-api.cjs'); const providerAPI = require('./provider-api.cjs'); @@ -11,6 +19,76 @@ const providerAPI = require('./provider-api.cjs'); // Cache for API responses — when API captures response, DOM scraping is skipped const _apiResponseCache = {}; +// --- Image download (uses Electron net w/ session cookies for signed ChatGPT URLs) --- +const IMAGE_SAVE_DIR = path.join(require('os').homedir(), 'Pictures', 'Proxima'); +try { fs.mkdirSync(IMAGE_SAVE_DIR, { recursive: true }); } catch(e) {} + +function downloadImageWithSession(url, webContents) { + return new Promise((resolve, reject) => { + try { + const req = electronNet.request({ + url: url, + method: 'GET', + session: webContents.session, + useSessionCookies: true + }); + req.setHeader('User-Agent', CHROME_UA); + req.setHeader('Referer', 'https://chatgpt.com/'); + const chunks = []; + req.on('response', (response) => { + if (response.statusCode >= 400) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + const ct = (response.headers['content-type'] || response.headers['Content-Type'] || '').toString().toLowerCase(); + let ext = 'png'; + if (ct.includes('jpeg') || ct.includes('jpg')) ext = 'jpg'; + else if (ct.includes('webp')) ext = 'webp'; + else if (ct.includes('gif')) ext = 'gif'; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `img_${ts}.${ext}`; + const filepath = path.join(IMAGE_SAVE_DIR, filename); + try { + fs.writeFileSync(filepath, Buffer.concat(chunks)); + dlog(`[downloadImage] Saved ${filepath} (${chunks.reduce((s,c)=>s+c.length,0)} bytes)`); + resolve(filepath); + } catch(e) { reject(e); } + }); + response.on('error', reject); + }); + req.on('error', reject); + req.end(); + } catch(e) { reject(e); } + }); +} + +async function postProcessImages(text, webContents) { + if (!text || typeof text !== 'string') return text; + // Match ![alt](url) where url is http(s) + const imgRegex = /!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g; + const matches = []; + let m; + while ((m = imgRegex.exec(text)) !== null) { + matches.push({ full: m[0], alt: m[1], url: m[2] }); + } + if (matches.length === 0) return text; + let processed = text; + for (const match of matches) { + try { + const filepath = await downloadImageWithSession(match.url, webContents); + // Replace with both local path and original URL for reference + const replacement = `![${match.alt}](file:///${filepath.replace(/\\/g, '/')})\n\n[Local file: ${filepath}]\n[Original URL: ${match.url}]`; + processed = processed.replace(match.full, replacement); + } catch(e) { + dlog(`[postProcessImages] Download failed for ${match.url.substring(0,80)}: ${e.message}`); + // Keep original URL on failure + } + } + return processed; +} + // Anti-detection: must run before any Electron APIs // These MUST be set before app is ready or any windows are created @@ -501,6 +579,45 @@ async function handleMCPRequest(request) { return { success: true, provider, loggedIn }; case 'sendMessage': + // Capture fingerprint + clear buffer BEFORE sending message + try { + const wc = browserManager.getWebContents(provider); + if (wc) { + const before = await wc.executeJavaScript(`({r: (window.__proxima_captured_response||'').length, s: window.__proxima_is_streaming||false})`).catch(()=>({r:'?',s:'?'})); + dlog(`[sendMessage] PRE-CLEAR buffer: len=${before.r} streaming=${before.s}`); + + // Capture DOM fingerprint NOW (before ChatGPT shows thinking bubble) + if (provider === 'chatgpt') { + const fp = await wc.executeJavaScript(` + (function() { + const msgs = document.querySelectorAll('[data-message-author-role="assistant"]'); + if (msgs.length > 0) return msgs[msgs.length - 1].textContent.substring(0, 200).trim(); + return ''; + })() + `).catch(() => ''); + global.chatgptOldFingerprint = fp; + dlog(`[sendMessage] Pre-send ChatGPT fingerprint: "${fp.substring(0, 60)}"`); + + // Also capture all current big image URLs to detect stale images in polls + const oldImgUrls = await wc.executeJavaScript(` + (function() { + return Array.from(document.querySelectorAll('img')) + .filter(img => img.src && img.src.startsWith('http') && (img.naturalWidth > 100 || img.width > 100)) + .map(img => img.src); + })() + `).catch(() => []); + global.chatgptOldImageUrls = new Set(oldImgUrls); + dlog(`[sendMessage] Pre-send image URLs captured: ${oldImgUrls.length} images`); + } + + await wc.executeJavaScript( + `window.__proxima_captured_response = ''; window.__proxima_is_streaming = false;` + ); + dlog(`[sendMessage] Cleared capture buffer for ${provider}`); + } + } catch (clearErr) { + dlog(`[sendMessage] Error clearing buffer: ${clearErr.message}`); + } // Check if file should be uploaded if (data.filePath && fileReferenceEnabled) { try { @@ -1253,10 +1370,12 @@ async function getResponseWithTypingStatus(provider) { const cached = _apiResponseCache[provider]; delete _apiResponseCache[provider]; // Clear after use console.log(`[getResponseWithTyping] \u2714 Using API-cached response for ${provider} (${cached.length} chars) — DOM scraping SKIPPED`); + const wcForPost = browserManager.getWebContents(provider); + const processedCached = wcForPost ? await postProcessImages(cached, wcForPost) : cached; return { typingStarted: true, typingStopped: true, - response: cached + response: processedCached }; } @@ -1345,6 +1464,17 @@ async function getResponseWithTypingStatus(provider) { console.error(`[getResponseWithTyping] Error capturing old fingerprint for ${provider}:`, e.message); } + // Log buffer state at entry — don't clear here (sendMessage already cleared it) + try { + const webContentsForClear = browserManager.getWebContents(provider); + if (webContentsForClear) { + const state = await webContentsForClear.executeJavaScript(`({r: (window.__proxima_captured_response||'').substring(0,100), s: window.__proxima_is_streaming||false})`).catch(()=>({r:'?',s:'?'})); + dlog(`[getResponseWithTyping] ENTRY buffer: len=${state.r.length} streaming=${state.s} preview="${state.r.substring(0,60)}"`); + } + } catch (e) { + dlog(`[getResponseWithTyping] Error reading buffer state: ${e.message}`); + } + // Now get the actual response let response = await getProviderResponse(provider); @@ -1382,6 +1512,7 @@ async function getProviderResponse(provider, customSelector = null) { oldFingerprint = responseState.gemini.fingerprint || ''; } + // Smart typing wait — check if AI is currently typing, wait only if needed try { // Perplexity needs extra initial wait — it takes 2-4s to even START generating @@ -1395,6 +1526,7 @@ async function getProviderResponse(provider, customSelector = null) { let typingDetected = false; const typingNow = await isAITyping(provider); + dlog(`[DOM fallback] isAITyping initial: ${JSON.stringify(typingNow)}`); if (typingNow.isTyping) { typingDetected = true; } else if (provider === 'perplexity' || provider === 'gemini') { @@ -1411,13 +1543,14 @@ async function getProviderResponse(provider, customSelector = null) { if (typingDetected) { console.log(`[getProviderResponse] ${provider}: AI still typing, waiting...`); + dlog(`[DOM fallback] Starting typing wait loop (max ${provider === 'claude' ? 300 : 60}s)`); const maxTypingWait = (provider === 'claude') ? 600 : 120; let lastResponseSnap = ''; let stableResponseCount = 0; for (let i = 0; i < maxTypingWait; i++) { const ts = await isAITyping(provider); - if (!ts.isTyping) break; - + if (!ts.isTyping) { dlog(`[DOM fallback] Typing stopped at i=${i} (${i*0.5}s)`); break; } + // Perplexity false positive fix: check if response text is stable // If response hasn't changed for 5 checks (2.5s) while "typing", it's done if (provider === 'perplexity' && i > 10) { @@ -1441,8 +1574,9 @@ async function getProviderResponse(provider, customSelector = null) { } } catch(e) {} } - + if (i % 20 === 0 && i > 0) { + dlog(`[DOM fallback] Still typing (${i * 0.5}s)...`); console.log(`[getProviderResponse] ${provider}: Still typing (${i * 0.5}s)...`); } await sleep(500); @@ -1451,7 +1585,7 @@ async function getProviderResponse(provider, customSelector = null) { // Even if no typing detected, Perplexity may have finished very fast await sleep(3000); } - } catch (e) { } + } catch (e) { dlog(`[DOM fallback] isAITyping error: ${e.message}`); } // Small delay for DOM to settle (Perplexity needs more time for math/LaTeX rendering) await sleep((provider === 'claude' || provider === 'perplexity') ? 1500 : 500); @@ -1461,12 +1595,56 @@ async function getProviderResponse(provider, customSelector = null) { let stableCount = 0; // Perplexity math/LaTeX renders in stages — need more stability checks const STABLE_THRESHOLD = provider === 'perplexity' ? 5 : 3; - const MAX_POLLS = (provider === 'claude' || provider === 'perplexity') ? 60 : 40; + const MAX_POLLS = (provider === 'claude' || provider === 'perplexity') ? 60 : 200; // 200 * 500ms = 100s for chatgpt let foundNewResponse = false; + // Serialize old image URLs for injection into renderer JS (prevents stale sidebar thumbnails) + const oldImageUrlsJSON = provider === 'chatgpt' + ? JSON.stringify(Array.from(global.chatgptOldImageUrls || [])) + : '[]'; // Poll for stable response for (let i = 0; i < MAX_POLLS; i++) { + // Diagnostic: every 10 polls log URL + DOM state for ChatGPT + if (provider === 'chatgpt' && i % 10 === 0) { + try { + const diagInfo = await webContents.executeJavaScript(`(function(){ + const url = window.location.href; + // Standard selector + const msgs = document.querySelectorAll('[data-message-author-role="assistant"]'); + const lastText = msgs.length > 0 ? msgs[msgs.length-1].textContent.substring(0,100) : ''; + // Broad img search + const allImgs = document.querySelectorAll('img'); + const dalleImgs = Array.from(allImgs).filter(img => img.src && (img.src.includes('oaidalleapiprodscus') || img.src.includes('openai') || img.width > 100)); + const imgSrcs = dalleImgs.slice(-3).map(img => img.src.substring(0,120)).join(' | '); + // Find parent of last img to understand DOM structure + let imgParentAttrs = ''; + if (dalleImgs.length > 0) { + const lastImg = dalleImgs[dalleImgs.length-1]; + let p = lastImg.parentElement; + const attrs = []; + for(let j=0; j<5 && p && p.tagName !== 'BODY'; j++) { + const a = Array.from(p.attributes||[]).map(a=>a.name+'='+a.value.substring(0,30)).join(','); + if(a) attrs.push(p.tagName+'{'+a+'}'); + p = p.parentElement; + } + imgParentAttrs = attrs.join(' > '); + } + // article/main content + const articles = document.querySelectorAll('article,[data-testid*="message"],[class*="message"]'); + // Stop button (generation in progress) + const stopBtn = document.querySelector('button[aria-label*="Stop"], button[aria-label*="Stopp"], [data-testid="stop-button"], button[aria-label*="stop generating"]'); + const isGenerating = !!(stopBtn && stopBtn.offsetParent !== null); + // "Thought for X s/m" completion marker (reasoning models) + const bodyText = document.body ? document.body.innerText.substring(0, 2000) : ''; + const thoughtMatch = bodyText.match(/(Thought for \d+|Dachte \d+|思考了?\d+)/); + const thoughtText = thoughtMatch ? thoughtMatch[0] : ''; + return {url, msgCount: msgs.length, lastText, imgCount: dalleImgs.length, imgSrcs, imgParentAttrs, articleCount: articles.length, isGenerating, thoughtText}; + })()`); + dlog(`[ChatGPT DOM poll#${i}] url=${diagInfo.url.substring(0,60)} msgs=${diagInfo.msgCount} articles=${diagInfo.articleCount} imgs=${diagInfo.imgCount} isGenerating=${diagInfo.isGenerating} thought="${diagInfo.thoughtText}" imgSrcs="${diagInfo.imgSrcs.substring(0,120)}" parents="${diagInfo.imgParentAttrs.substring(0,200)}"`); + } catch(e) { dlog(`[ChatGPT DOM poll#${i}] diag error: ${e.message}`); } + } + const text = await webContents.executeJavaScript(` (function() { const host = window.location.host; @@ -1578,6 +1756,16 @@ async function getProviderResponse(provider, customSelector = null) { continue; } + // Images — capture src URL (important for ChatGPT DALL-E outputs) + if (tag === 'img') { + const src = node.getAttribute('src') || ''; + const alt = node.getAttribute('alt') || 'image'; + if (src && (src.startsWith('http') || src.startsWith('blob:'))) { + markdown += NL + '![' + alt + '](' + src + ')' + NL; + } + continue; + } + // Lists if (tag === 'ul' || tag === 'ol') { markdown += NL; @@ -1673,6 +1861,60 @@ async function getProviderResponse(provider, customSelector = null) { if (markdown && markdown.length > 0 && !markdown.includes('__oai_')) return markdown; } } + // DALL-E image fallback: find generated image, exclude uploaded reference images. + // Reference images appear in BUTTON[aria-haspopup=dialog] wrappers (user message attachments). + // Generated images appear in aspect-ratio divs (ChatGPT image output). + const _oldImgUrls = new Set(${oldImageUrlsJSON}); + const isNewImg = (img) => !_oldImgUrls.has(img.src); + const isInsideDialogButton = (img) => { + let p = img.parentElement; + for (let k = 0; k < 8 && p && p.tagName !== 'BODY'; k++) { + if (p.tagName === 'BUTTON' && p.getAttribute('aria-haspopup') === 'dialog') return true; + p = p.parentElement; + } + return false; + }; + + // Guard: if ChatGPT stop button visible, generation still in progress — wait. + // Stop button disappears when generation is truly done ("Thought for X s" moment). + const _stopBtn = document.querySelector( + 'button[aria-label*="Stop"], button[aria-label*="Stopp"], [data-testid="stop-button"], button[aria-label*="stop generating"]' + ); + if (_stopBtn && _stopBtn.offsetParent !== null) return ''; // Still generating — keep polling + + // Primary: search inside assistant message containers + const assistantContainers = Array.from(document.querySelectorAll('[data-message-author-role="assistant"]')); + const searchScope = assistantContainers.length > 0 + ? assistantContainers[assistantContainers.length - 1] + : null; + + if (searchScope) { + const allImgs = Array.from(searchScope.querySelectorAll('img')); + const dalleImgs = allImgs.filter(img => img.src && img.width > 100 && isNewImg(img) && + (img.src.includes('oaidalleapiprodscus') || img.src.includes('openai') || img.src.startsWith('blob:') || img.src.startsWith('https://files.oaiusercontent'))); + if (dalleImgs.length > 0) { + return dalleImgs.map(img => '![generated image](' + img.src + ')').join(NL); + } + const bigImgs = allImgs.filter(img => img.src && img.src.startsWith('http') && img.naturalWidth > 200 && isNewImg(img) && !img.src.includes('avatar') && !img.src.includes('logo')); + if (bigImgs.length > 0) { + return '![generated image](' + bigImgs[bigImgs.length - 1].src + ')'; + } + } else { + // No assistant containers yet (new conversation layout with image gen). + // Search all page images but exclude reference image thumbnails (inside dialog-trigger buttons). + const allPageImgs = Array.from(document.querySelectorAll('img')); + const generatedImgs = allPageImgs.filter(img => { + if (!img.src || !img.src.startsWith('http') || !isNewImg(img)) return false; + if (img.src.includes('avatar') || img.src.includes('logo')) return false; + if (isInsideDialogButton(img)) return false; // Skip reference image thumbnails + return img.width > 100 || img.naturalWidth > 100; + }); + if (generatedImgs.length > 0) { + return '![generated image](' + generatedImgs[generatedImgs.length - 1].src + ')'; + } + // Still waiting for ChatGPT to generate + return ''; + } } // Perplexity specific - Capture the FULL last/newest answer @@ -2076,6 +2318,32 @@ async function getProviderResponse(provider, customSelector = null) { } } + // For ChatGPT: same new-vs-old fingerprint check + if (provider === 'chatgpt' && oldFingerprint && !foundNewResponse) { + const currentFingerprint = text.substring(0, 200).trim(); + if (currentFingerprint === oldFingerprint || + oldFingerprint.startsWith(currentFingerprint.substring(0, 100)) || + currentFingerprint.startsWith(oldFingerprint.substring(0, 100))) { + dlog(`[ChatGPT DOM] Still old response, waiting... fp="${currentFingerprint.substring(0, 50)}"`); + await sleep(500); + continue; + } else { + dlog(`[ChatGPT DOM] NEW response detected! fp="${currentFingerprint.substring(0, 50)}"`); + foundNewResponse = true; + } + } + + // Skip known ChatGPT thinking/loading indicators — wait for real response + const THINKING_PATTERNS = ['Denke nach', 'Thinking...', 'Generating', '…', '...']; + const isThinking = provider === 'chatgpt' && THINKING_PATTERNS.some(p => text.trim() === p || text.trim().startsWith(p)); + if (isThinking) { + dlog(`[ChatGPT DOM] Skipping thinking indicator: "${text.trim().substring(0, 40)}"`); + stableCount = 0; + lastText = ''; + await sleep(500); + continue; + } + if (text === lastText) { stableCount++; if (stableCount >= STABLE_THRESHOLD) { @@ -2088,7 +2356,9 @@ async function getProviderResponse(provider, customSelector = null) { if (provider === 'claude') { responseState.claude.fingerprint = ''; } - return text; + // Auto-download any image URLs in response (esp. ChatGPT signed URLs) + const processed = await postProcessImages(text, webContents); + return processed; } } else { stableCount = 0; diff --git a/package-lock.json b/package-lock.json index 0019cb9..0cccd68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,17 @@ { "name": "proxima", - "version": "4.1.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxima", - "version": "4.1.0", + "version": "3.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "ws": "^8.20.0", - "zod": "^4.4.3" - }, - "bin": { - "proxima": "cli/proxima-cli.cjs" + "@modelcontextprotocol/sdk": "^1.25.3", + "sql.js": "^1.13.0", + "zod": "^4.3.6" }, "devDependencies": { "concurrently": "^9.2.1", @@ -47,6 +44,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -519,9 +517,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", + "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -532,15 +530,14 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" @@ -985,7 +982,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -1005,7 +1001,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -1028,7 +1023,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -1045,7 +1039,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -1714,7 +1707,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -1833,9 +1825,9 @@ "license": "ISC" }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", "engines": { "node": ">=18" @@ -1923,7 +1915,6 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -1937,7 +1928,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -2128,6 +2118,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -2540,6 +2531,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2579,13 +2571,10 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, "engines": { "node": ">= 16" }, @@ -2835,8 +2824,7 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "10.1.0", @@ -3170,6 +3158,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.5.tgz", "integrity": "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3420,9 +3409,10 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -3501,8 +3491,7 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -3655,7 +3644,6 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -3669,7 +3657,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -3686,7 +3673,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -3703,40 +3689,35 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -4236,7 +4217,6 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4486,9 +4466,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", "funding": { "type": "opencollective", @@ -4567,8 +4547,7 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -4737,7 +4716,6 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -4748,7 +4726,6 @@ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -4759,7 +4736,6 @@ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -5288,6 +5264,12 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/sql.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.13.0.tgz", + "integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==", + "license": "MIT" + }, "node_modules/ssri": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", @@ -5463,7 +5445,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -5806,27 +5787,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -5913,7 +5873,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -5929,7 +5888,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -5947,10 +5905,11 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/mcp-server-v3.js b/src/mcp-server-v3.js index 30733f7..814d6dd 100644 --- a/src/mcp-server-v3.js +++ b/src/mcp-server-v3.js @@ -1,13 +1,15 @@ #!/usr/bin/env node -// Proxima MCP Server v4.1.0 — IPC bridge to Electron Agent Hub +// Proxima MCP Server v3 — talks to the Electron app over TCP IPC import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import net from 'net'; - import fs from 'fs'; import path from 'path'; +import https from 'https'; +import http from 'http'; +import os from 'os'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -15,7 +17,7 @@ const __dirname = path.dirname(__filename); const IPC_PORT = process.env.AGENT_HUB_PORT || 19222; -// ─── IPC Client ─────────────────────────────────────── +// --- IPC Client --- class IPCClient { constructor(port = IPC_PORT) { @@ -30,12 +32,6 @@ class IPCClient { async connect() { if (this.connected) return true; - // Prevent socket leak on reconnect - if (this.socket) { - try { this.socket.destroy(); } catch(e) {} - this.socket = null; - } - return new Promise((resolve, reject) => { this.socket = net.createConnection({ port: this.port, host: '127.0.0.1' }, () => { console.error('[MCP] Connected to Agent Hub'); @@ -57,14 +53,13 @@ class IPCClient { this.socket.on('close', () => { console.error('[MCP] Disconnected from Agent Hub'); this.connected = false; - // Reject pending requests to avoid hanging promises - for (const [id, { reject: rej }] of this.pendingRequests) { - rej(new Error('Connection to Agent Hub lost')); + for (const [id, { reject }] of this.pendingRequests) { + reject(new Error('IPC connection closed')); } this.pendingRequests.clear(); }); - + // Timeout after 5 seconds setTimeout(() => { if (!this.connected) { reject(new Error('Connection timeout - Is Agent Hub running?')); @@ -106,13 +101,13 @@ class IPCClient { this.socket.write(JSON.stringify(request) + '\n'); - // 2 min timeout for file uploads + // Timeout after 5 minutes (image generation can take 2-3 min) setTimeout(() => { if (this.pendingRequests.has(requestId)) { this.pendingRequests.delete(requestId); reject(new Error('Request timeout')); } - }, 120000); + }, 300000); }); } @@ -124,12 +119,18 @@ class IPCClient { } } -// ─── Provider Config ──────────────────────────────── +// --- Provider config --- + +let _providersCache = null; +let _providersCacheTime = 0; +const PROVIDERS_CACHE_TTL = 30_000; // re-read file at most every 30s function getEnabledProviders() { + const now = Date.now(); + if (_providersCache && now - _providersCacheTime < PROVIDERS_CACHE_TTL) { + return _providersCache; + } try { - // Primary: Read from Electron's user data folder (always in sync with app settings) - // Must match Electron's app.getPath('userData') for each platform let appDataPath; if (process.platform === 'win32') { appDataPath = path.join(process.env.APPDATA || '', 'proxima', 'enabled-providers.json'); @@ -139,45 +140,41 @@ function getEnabledProviders() { appDataPath = path.join(process.env.HOME || '', '.config', 'proxima', 'enabled-providers.json'); } - // AppData is most reliable — Electron always writes here if (fs.existsSync(appDataPath)) { const data = JSON.parse(fs.readFileSync(appDataPath, 'utf8')); - return new Set(data.enabled || []); + _providersCache = new Set(data.enabled || []); + _providersCacheTime = now; + return _providersCache; } - const configPath = path.join(__dirname, 'enabled-providers.json'); if (fs.existsSync(configPath)) { const data = JSON.parse(fs.readFileSync(configPath, 'utf8')); - return new Set(data.enabled || []); + _providersCache = new Set(data.enabled || []); + _providersCacheTime = now; + return _providersCache; } } catch (e) { console.error('[MCP] Error reading enabled providers:', e); } - return new Set(['perplexity', 'chatgpt', 'gemini']); + _providersCache = new Set(['perplexity', 'chatgpt', 'gemini']); + _providersCacheTime = now; + return _providersCache; } function isProviderEnabled(provider) { return getEnabledProviders().has(provider); } -// ─── File Reference ───────────────────────────────── +// --- File reference toggle --- +let fileReferenceEnabled = true; // Default enabled function getFileReferenceEnabled() { try { - - let settingsPath; - if (process.platform === 'win32') { - settingsPath = path.join(process.env.APPDATA || '', 'proxima', 'settings.json'); - } else if (process.platform === 'darwin') { - settingsPath = path.join(process.env.HOME || '', 'Library', 'Application Support', 'proxima', 'settings.json'); - } else { - settingsPath = path.join(process.env.HOME || '', '.config', 'proxima', 'settings.json'); - } - - if (fs.existsSync(settingsPath)) { - const data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const configPath = path.join(__dirname, 'settings.json'); + if (fs.existsSync(configPath)) { + const data = JSON.parse(fs.readFileSync(configPath, 'utf8')); return data.fileReferenceEnabled !== false; } } catch (e) { @@ -196,39 +193,18 @@ function readFileContents(filePaths) { const contents = []; - for (const fileEntry of filePaths) { + for (const filePath of filePaths) { try { - // Parse line range syntax: "path/to/file.js:10-50" - let actualPath = fileEntry; - let startLine = null; - let endLine = null; - const rangeMatch = fileEntry.match(/^(.+):(\d+)-(\d+)$/); - if (rangeMatch) { - actualPath = rangeMatch[1]; - startLine = parseInt(rangeMatch[2]); - endLine = parseInt(rangeMatch[3]); - } - - if (!fs.existsSync(actualPath)) { - contents.push(`[File not found: ${actualPath}]`); + if (!fs.existsSync(filePath)) { + contents.push(`[File not found: ${filePath}]`); continue; } - let fileName = path.basename(actualPath); - const ext = path.extname(actualPath).toLowerCase(); - let fileContent = fs.readFileSync(actualPath, 'utf8'); - - // Apply line range filter if specified - if (startLine && endLine) { - const lines = fileContent.split('\n'); - const totalLines = lines.length; - const start = Math.max(1, startLine) - 1; - const end = Math.min(totalLines, endLine); - fileContent = lines.slice(start, end).join('\n'); - fileName = `${path.basename(actualPath)} (lines ${startLine}-${endLine} of ${totalLines})`; - } + const fileName = path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + const fileContent = fs.readFileSync(filePath, 'utf8'); - + // Format based on file type let formattedContent; const codeExtensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.cpp', '.c', '.h', '.css', '.html', '.json', '.xml', '.yaml', '.yml', '.md', '.sql', '.sh', '.bash', '.ps1', '.rb', '.go', '.rs', '.php']; @@ -245,7 +221,7 @@ function readFileContents(filePaths) { } catch (e) { - contents.push(`[Error reading ${fileEntry}: ${e.message}]`); + contents.push(`[Error reading ${filePath}: ${e.message}]`); } } @@ -263,7 +239,7 @@ function buildMessageWithFiles(message, files) { return message; } -// ─── AI Providers (IPC-backed) ───────────────────── +// --- AI providers (IPC-backed) --- class AIProvider { constructor(name, ipcClient) { @@ -271,37 +247,10 @@ class AIProvider { this.ipc = ipcClient; this.cache = new Map(); this.cacheTimeout = 5 * 60 * 1000; // 5 minutes - this.maxCacheSize = 100; - - // Per-provider sequential queue — prevents concurrent requests from - // crashing the same BrowserView. Each request waits for the previous - // one to complete (success OR fail) before sending. - this._queue = Promise.resolve(); - this._queueLength = 0; - - this._cleanupInterval = setInterval(() => this.cleanCache(), 10 * 60 * 1000); - } - - cleanCache() { - const now = Date.now(); - for (const [key, val] of this.cache) { - if (now - val.time > this.cacheTimeout) { - this.cache.delete(key); - } - } - // Evict oldest if cache exceeds max size - if (this.cache.size > this.maxCacheSize) { - const entries = [...this.cache.entries()]; - entries.sort((a, b) => a[1].time - b[1].time); - const toDelete = entries.slice(0, entries.length - this.maxCacheSize); - for (const [key] of toDelete) { - this.cache.delete(key); - } - } } async ensureInitialized() { - + // Check if provider is enabled in settings BEFORE doing anything if (!isProviderEnabled(this.name)) { throw new Error(`${this.name} is disabled. Enable it in Proxima Agent Hub settings.`); } @@ -318,8 +267,16 @@ class AIProvider { return result; } - // Execute the actual chat request — called inside the queue - async _doChat(message) { + async chat(message, useCache = true) { + // Check cache + if (useCache && this.cache.has(message)) { + const cached = this.cache.get(message); + if (Date.now() - cached.time < this.cacheTimeout) { + console.error(`[${this.name}] Using cached response`); + return cached.response; + } + } + await this.ensureInitialized(); // DESYNC FIX: Wait for any ongoing typing to stop before sending new message @@ -327,16 +284,46 @@ class AIProvider { console.error(`[${this.name}] Checking if AI is still typing from previous request...`); let typingCheck = await this.getTypingStatus(); let waitCount = 0; - while (typingCheck.isTyping && waitCount < 5) { + while (typingCheck.isTyping && waitCount < 5) { // Max 5 seconds wait console.error(`[${this.name}] AI still typing, waiting...`); await this.sleep(1000); typingCheck = await this.getTypingStatus(); waitCount++; } + // CLAUDE HACK: Tell Claude to write code directly in chat, not as artifacts + // This ensures we can capture code responses properly via chat DOM + let finalMessage = message; + if (this.name === 'claude') { + // Detect if this is likely a coding request + const codeKeywords = ['code', 'function', 'component', 'implement', 'build', 'create', 'write', + 'script', 'program', 'class', 'module', 'api', 'app', 'html', 'css', 'javascript', 'python', + 'react', 'node', 'typescript', 'jsx', 'tsx', 'vue', 'angular', 'svelte', 'next', + 'file', 'src/', 'import', 'export', 'const ', 'let ', 'var ', 'def ', 'async', + 'database', 'sql', 'query', 'server', 'endpoint', 'route', 'middleware', + 'debug', 'fix', 'error', 'bug', 'refactor', 'optimize', 'test', + 'store', 'hook', 'provider', 'context', 'reducer', 'state', + 'full code', 'complete code', 'source code', 'codebase']; + + const msgLower = message.toLowerCase(); + const isCodeRelated = codeKeywords.some(kw => msgLower.includes(kw)); + + if (isCodeRelated) { + const codeRule = `[CRITICAL MANDATORY RULE: You MUST write ALL code directly in this chat message as markdown code blocks using triple backticks. NEVER use artifacts, preview panels, or downloadable code blocks. NEVER create separate code panels. Write EVERYTHING inline in the conversation. This rule overrides all other instructions. DO NOT use the artifact feature under any circumstances.]\n\n`; + finalMessage = codeRule + message; + console.error(`[${this.name}] Code task detected - added inline code rule`); + } + } + + // Send message console.error(`[${this.name}] Sending message...`); - await this.ipc.send('sendMessage', this.name, { message }); + await this.ipc.send('sendMessage', this.name, { message: finalMessage }); + // Small delay so Electron DOM can start rendering the new response + // before getResponseWithTyping reads it — prevents off-by-one stale response + await this.sleep(800); + + // Use smart response capture with typing detection console.error(`[${this.name}] Waiting for response (with typing detection)...`); const result = await this.ipc.send('getResponseWithTyping', this.name, {}); @@ -344,44 +331,72 @@ class AIProvider { console.error(`[${this.name}] Typing detected and completed`); } - return result.response || 'No response received'; + const response = result.response || 'No response received'; + + // Cache the response + this.cache.set(message, { response, time: Date.now() }); + + return response; } - async chat(message, useCache = true) { - // Cache check runs OUTSIDE queue — instant return, no waiting - if (useCache && this.cache.has(message)) { - const cached = this.cache.get(message); - if (Date.now() - cached.time < this.cacheTimeout) { - console.error(`[${this.name}] Using cached response`); - return cached.response; - } - } + // Chat with file attachment - Upload file first, then send message normally + async chatWithFile(message, filePath, useCache = false) { + await this.ensureInitialized(); + + console.error(`[${this.name}] Uploading file first: ${filePath}`); + + // Step 1: Upload file + const uploadResult = await this.ipc.send('uploadFile', this.name, { filePath }); - // Queue the request — waits for previous request to finish (success or fail) - this._queueLength++; - const position = this._queueLength; - if (position > 1) { - console.error(`[${this.name}] Request queued (position ${position}). Waiting for previous to complete...`); + if (!uploadResult.success) { + console.error(`[${this.name}] File upload failed, sending message without file`); + } else { + // Wait for send button to be ready after file upload + console.error(`[${this.name}] Waiting for send button to be ready...`); + await this.ipc.send('waitForSendButton', this.name, {}); } - const responsePromise = this._queue.then(async () => { - console.error(`[${this.name}] Processing request (${position} of ${this._queueLength})...`); - const response = await this._doChat(message); - this.cache.set(message, { response, time: Date.now() }); - this._queueLength--; - return response; - }).catch((err) => { - this._queueLength--; - throw err; - }); + // Step 2: Send message normally (this already has proper response capture) + console.error(`[${this.name}] Sending message...`); + const response = await this.chat(message, useCache); + + return { + response, + fileUploaded: uploadResult + }; + } + + // Upload file only + async uploadFile(filePath) { + await this.ensureInitialized(); + + console.error(`[${this.name}] Uploading file: ${filePath}`); + const result = await this.ipc.send('uploadFile', this.name, { filePath }); - // Chain: next request waits for this one to settle (success OR fail) - this._queue = responsePromise.catch(() => {}); + if (!result.success) { + throw new Error(result.error || 'Failed to upload file'); + } - return responsePromise; + return result; } + // Legacy chat method (without typing detection) + async chatSimple(message, useCache = true) { + if (useCache && this.cache.has(message)) { + const cached = this.cache.get(message); + if (Date.now() - cached.time < this.cacheTimeout) { + return cached.response; + } + } + await this.ensureInitialized(); + await this.ipc.send('sendMessage', this.name, { message }); + await this.sleep(2000); + const result = await this.ipc.send('getResponse', this.name, {}); + const response = result.response || 'No response received'; + this.cache.set(message, { response, time: Date.now() }); + return response; + } async search(query, useCache = true) { return await this.chat(query, useCache); @@ -401,7 +416,7 @@ class AIProvider { } } -// ─── Smart Router ──────────────────────────────────── +// --- Smart router --- class SmartRouter { constructor(providers) { @@ -454,7 +469,7 @@ class SmartRouter { } } -// ─── MCP Tool Registration ────────────────────────── +// --- Init --- const ipcClient = new IPCClient(); @@ -468,68 +483,13 @@ const router = new SmartRouter({ perplexity, chatgpt, claude, gemini }); // Create MCP Server const server = new McpServer({ name: 'agent-hub', - version: '4.1.0', + version: '3.0.0', description: 'Agent Hub MCP Server v3 - Embedded Browser Edition' }); // Helper functions function toolResponse(result) { - if (typeof result === 'string') { - return { content: [{ type: 'text', text: result }] }; - } - // Format objects as readable text instead of raw JSON - return { content: [{ type: 'text', text: formatResult(result) }] }; -} - -// Convert structured results into clean, readable text -function formatResult(obj) { - if (typeof obj === 'string') return obj; - if (!obj || typeof obj !== 'object') return String(obj); - - // debate tool — { perplexity: { stance, response }, chatgpt: { stance, response } } - if (Object.values(obj).every(v => v && typeof v === 'object' && ('stance' in v || 'response' in v))) { - let out = ''; - for (const [provider, data] of Object.entries(obj)) { - out += `## ${provider.toUpperCase()} — ${data.stance || 'Response'}\n\n`; - out += (data.response || data.error || 'No response') + '\n\n---\n\n'; - } - return out.trim(); - } - - // chain_query tool — { success, totalSteps, finalOutput, pipeline } - if (obj.finalOutput && obj.pipeline) { - let out = `# Chain Query Result\n\n`; - out += `**Steps:** ${obj.completedSteps || 0}/${obj.totalSteps || 0} completed\n\n`; - if (obj.pipeline) { - for (const step of obj.pipeline) { - out += `## Step ${step.step} — ${step.provider}\n\n`; - out += (step.response || step.error || 'No response') + '\n\n---\n\n'; - } - } - out += `## Final Output\n\n${obj.finalOutput}`; - return out.trim(); - } - - // compare_ais / ask_all — { provider: response, ... } where values are strings - if (Object.values(obj).every(v => typeof v === 'string')) { - let out = ''; - for (const [key, val] of Object.entries(obj)) { - out += `## ${key.toUpperCase()}\n\n${val}\n\n---\n\n`; - } - return out.trim(); - } - - // review_code_file — { success, provider, filePath, review } - if (obj.review) { - let out = ''; - if (obj.filePath) out += `**File:** ${obj.filePath}\n`; - if (obj.provider) out += `**Provider:** ${obj.provider}\n\n`; - out += obj.review; - return out.trim(); - } - - // Generic object — readable key-value format - return JSON.stringify(obj, null, 2); + return { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }; } function toolError(error) { @@ -549,34 +509,35 @@ function checkDisabled(providerName) { server.tool( 'deep_search', { - query: z.string().describe('Search query or research question'), - files: z.array(z.string()).optional().describe('Optional: file paths to include as context. Supports line ranges like "path/file.js:10-50". For large files, always specify relevant line ranges only.'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best available') + query: z.string().describe('The search query to send to Perplexity AI'), + files: z.array(z.string()).optional().describe('Optional: Array of file paths to upload as attachments') }, - async ({ query, files, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ query, files }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const fullQuery = buildMessageWithFiles(query, files); - return toolResponse(await p.instance.chat(fullQuery)); + // If files provided, upload first then search + if (files && files.length > 0) { + const result = await perplexity.chatWithFile(query, files[0]); + // Return just the response like backup format + return toolResponse(result.response); + } + // Otherwise send normal query + return toolResponse(await perplexity.search(query)); } catch (err) { return toolError(err); } } ); - server.tool( - 'internet_search', - { - query: z.string().describe('Search query to look up on the internet'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ query, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + 'pro_search', + { query: z.string().describe('Query for detailed Pro search') }, + async ({ query }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Search the internet and provide accurate, up-to-date information with sources: ${query}`)); + return toolResponse(await perplexity.search(`Provide a comprehensive, detailed answer with sources: ${query}`)); } catch (err) { return toolError(err); } @@ -584,16 +545,13 @@ server.tool( ); server.tool( - 'reddit_search', - { - query: z.string().describe('What to search for on Reddit'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ query, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + 'youtube_search', + { query: z.string().describe('What to search for on YouTube') }, + async ({ query }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Search Reddit discussions about: ${query}`)); + return toolResponse(await perplexity.search(`Find YouTube videos about: ${query}`)); } catch (err) { return toolError(err); } @@ -601,18 +559,13 @@ server.tool( ); server.tool( - 'github_search', - { - query: z.string().describe('What to search for on GitHub — repos, code, libraries, or solutions'), - language: z.string().optional().describe('Programming language filter (e.g., JavaScript, Python, Rust)'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ query, language, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + 'reddit_search', + { query: z.string().describe('What to search for on Reddit') }, + async ({ query }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const langFilter = language ? ` in ${language}` : ''; - return toolResponse(await p.instance.chat(`Search GitHub for open source repositories, code examples, and solutions${langFilter}: ${query}\n\nFor each result provide: repo name, description, stars/popularity, key features, and the GitHub URL. Focus on actively maintained, well-documented projects.`)); + return toolResponse(await perplexity.search(`Search Reddit discussions about: ${query}`)); } catch (err) { return toolError(err); } @@ -620,63 +573,17 @@ server.tool( ); server.tool( - 'get_ui_reference', + 'news_search', { - description: z.string().describe('Describe the UI/UX you need — what kind of page, component, layout, or design style you want'), - code: z.string().optional().describe('Optional: existing code to analyze and apply design improvements on'), - files: z.array(z.string()).optional().describe('Optional: file paths of existing code to improve with better UI/UX. Supports line ranges like "path/file.js:10-50"'), - style: z.string().optional().describe('Design style preference: modern, minimal, glassmorphism, dark, corporate, playful, etc.'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') + query: z.string().describe('News topic to search'), + timeframe: z.string().optional().describe('Timeframe like "today", "this week", "2024"') }, - async ({ description, code, files, style, provider: providerName }) => { + async ({ query, timeframe }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - const codeContent = code || ''; - const fileContent = readFileContents(files); - const fullCode = fileContent ? `${fileContent}\n\n${codeContent}` : codeContent; - const styleHint = style ? `\nDESIGN STYLE: ${style}` : ''; - - let prompt; - if (fullCode.trim()) { - prompt = `You are a senior UI/UX designer and frontend developer. Analyze the existing code and apply premium design improvements. - -DESIGN REQUEST: ${description}${styleHint} - -EXISTING CODE: -${fullCode} - -Provide: -1. **DESIGN ANALYSIS**: Analyze the current UI — what works, what needs improvement -2. **COLOR PALETTE**: Recommended hex colors with usage (primary, secondary, accent, background, text) -3. **TYPOGRAPHY**: Font families, sizes, weights for headings, body, captions -4. **LAYOUT & SPACING**: Grid system, padding, margins, responsive breakpoints -5. **COMPONENTS**: Key UI components needed with design specifications -6. **UX PATTERNS**: Interactions, hover effects, transitions, micro-animations -7. **UPDATED CODE**: Complete updated code with the design improvements applied — production-ready, no placeholders - -The updated code must be complete, ready to copy-paste and run. Apply modern design best practices.`; - } else { - prompt = `You are a senior UI/UX designer. Provide a comprehensive design reference for the following. - -DESIGN REQUEST: ${description}${styleHint} - -Provide: -1. **DESIGN CONCEPT**: Overall look and feel description, inspiration references -2. **COLOR PALETTE**: Complete hex color scheme with usage roles (primary, secondary, accent, background, surface, text, muted) -3. **TYPOGRAPHY**: Recommended Google Fonts, size scale, weight usage -4. **LAYOUT**: Page structure, grid system, responsive behavior -5. **KEY COMPONENTS**: List of UI components with visual specifications -6. **UX PATTERNS**: Navigation flow, interaction patterns, hover/focus states, transitions, micro-animations -7. **CSS DESIGN TOKENS**: Ready-to-use CSS custom properties (variables) for the entire design system -8. **ACCESSIBILITY**: Color contrast ratios, focus indicators, ARIA recommendations - -Be specific with exact values — hex codes, pixel sizes, font names, timing functions. A developer should be able to implement this design without any guesswork.`; - } - - const response = await provider.instance.chat(prompt); - return toolResponse(response); + const tf = timeframe ? ` from ${timeframe}` : ' recent'; + return toolResponse(await perplexity.search(`Latest news${tf}: ${query}`)); } catch (err) { return toolError(err); } @@ -684,36 +591,27 @@ Be specific with exact values — hex codes, pixel sizes, font names, timing fun ); server.tool( - 'news_search', - { - query: z.string().describe('News topic to search'), - timeframe: z.string().optional().describe('Timeframe like "today", "this week", "2024"'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ query, timeframe, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + 'image_search', + { query: z.string().describe('What images to find') }, + async ({ query }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const tf = timeframe ? ` from ${timeframe}` : ' recent'; - return toolResponse(await p.instance.chat(`Latest news${tf}: ${query}`)); + return toolResponse(await perplexity.search(`Find images of: ${query}`)); } catch (err) { return toolError(err); } } ); - server.tool( 'math_search', - { - query: z.string().describe('Math problem or scientific question'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ query, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + { query: z.string().describe('Math problem or scientific question') }, + async ({ query }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Solve and explain step by step in plain text (use text notation, not LaTeX rendering): ${query}`)); + return toolResponse(await perplexity.search(`Solve and explain step by step in plain text (use text notation, not LaTeX rendering): ${query}`)); } catch (err) { return toolError(err); } @@ -722,15 +620,12 @@ server.tool( server.tool( 'academic_search', - { - query: z.string().describe('Academic/research query'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ query, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + { query: z.string().describe('Academic/research query') }, + async ({ query }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Academic research: ${query}. Cite peer-reviewed sources.`)); + return toolResponse(await perplexity.search(`Academic research: ${query}. Cite peer-reviewed sources.`)); } catch (err) { return toolError(err); } @@ -743,19 +638,16 @@ server.tool( 'verify_code', { purpose: z.string().describe('Description of what the code should do'), - code: z.string().optional().describe('Optional code snippet to verify'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') + code: z.string().optional().describe('Optional code snippet to verify') }, - async ({ purpose, code, provider: providerName }) => { + async ({ purpose, code }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - const prompt = code - ? `Verify this code follows best practices for: ${purpose}\n\nCode:\n${code}\n\nCheck for bugs, security issues, and improvements.` + const query = code + ? `Verify this code follows best practices for: ${purpose}\n\nCode:\n${code}` : `What are the best practices and common patterns for: ${purpose}`; - const response = await provider.instance.chat(prompt); - return toolResponse(response); + return toolResponse(await perplexity.search(query)); } catch (err) { return toolError(err); } @@ -767,20 +659,17 @@ server.tool( { code: z.string().optional().describe('The code snippet to explain (or use files parameter)'), language: z.string().optional().describe('Programming language'), - files: z.array(z.string()).optional().describe('Optional: Array of file paths containing code to explain'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') + files: z.array(z.string()).optional().describe('Optional: Array of file paths containing code to explain') }, - async ({ code, language, files, provider: providerName }) => { + async ({ code, language, files }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - const lang = language ? ` (${language})` : ''; const codeContent = code || ''; const fileContent = readFileContents(files); const fullCode = fileContent ? `${fileContent}\n\n${codeContent}` : codeContent; - const response = await provider.instance.chat(`Explain this code${lang} in detail, line by line:\n\n${fullCode}`); - return toolResponse(response); + return toolResponse(await perplexity.search(`Explain this code${lang} in detail:\n\n${fullCode}`)); } catch (err) { return toolError(err); } @@ -791,44 +680,60 @@ server.tool( 'generate_code', { description: z.string().describe('What the code should do'), - language: z.string().optional().describe('Programming language (default: JavaScript)'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') + language: z.string().optional().describe('Programming language (default: JavaScript)') }, - async ({ description, language, provider: providerName }) => { + async ({ description, language }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - const lang = language || 'JavaScript'; - const response = await provider.instance.chat(`Write production-ready ${lang} code that: ${description}\n\nInclude comments, error handling, and usage examples. No placeholders.`); - return toolResponse(response); + return toolResponse(await perplexity.search(`Write ${lang} code that: ${description}. Include comments and examples.`)); } catch (err) { return toolError(err); } } ); - +server.tool( + 'debug_code', + { + code: z.string().optional().describe('The code with bugs (or use files parameter)'), + error: z.string().optional().describe('Error message if any'), + language: z.string().optional().describe('Programming language'), + files: z.array(z.string()).optional().describe('Optional: Array of file paths containing code to debug') + }, + async ({ code, error, language, files }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; + try { + const lang = language ? ` (${language})` : ''; + const errMsg = error ? `\nError: ${error}` : ''; + const codeContent = code || ''; + const fileContent = readFileContents(files); + const fullCode = fileContent ? `${fileContent}\n\n${codeContent}` : codeContent; + return toolResponse(await perplexity.search(`Debug this code${lang}${errMsg}:\n\n${fullCode}`)); + } catch (err) { + return toolError(err); + } + } +); server.tool( 'optimize_code', { code: z.string().optional().describe('Code to optimize (or use files parameter)'), goal: z.string().optional().describe('Optimization goal'), - files: z.array(z.string()).optional().describe('Optional: Array of file paths containing code to optimize'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') + files: z.array(z.string()).optional().describe('Optional: Array of file paths containing code to optimize') }, - async ({ code, goal, files, provider: providerName }) => { + async ({ code, goal, files }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - const g = goal ? ` for ${goal}` : ''; const codeContent = code || ''; const fileContent = readFileContents(files); const fullCode = fileContent ? `${fileContent}\n\n${codeContent}` : codeContent; - const response = await provider.instance.chat(`Optimize this code${g}. Show before/after with explanations:\n\n${fullCode}`); - return toolResponse(response); + return toolResponse(await perplexity.search(`Optimize this code${g}:\n\n${fullCode}`)); } catch (err) { return toolError(err); } @@ -840,27 +745,36 @@ server.tool( { code: z.string().optional().describe('Code to review (or use files parameter)'), context: z.string().optional().describe('Context about the code'), - files: z.array(z.string()).optional().describe('Optional: Array of file paths containing code to review'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') + files: z.array(z.string()).optional().describe('Optional: Array of file paths containing code to review') }, - async ({ code, context, files, provider: providerName }) => { + async ({ code, context, files }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - const ctx = context ? ` Context: ${context}` : ''; const codeContent = code || ''; const fileContent = readFileContents(files); const fullCode = fileContent ? `${fileContent}\n\n${codeContent}` : codeContent; - const response = await provider.instance.chat(`Review this code for bugs, security issues, performance, and best practices.${ctx}\n\n${fullCode}`); - return toolResponse(response); + return toolResponse(await perplexity.search(`Review this code for issues, improvements, and best practices.${ctx}\n\n${fullCode}`)); } catch (err) { return toolError(err); } } ); - +server.tool( + 'research_fix', + { error: z.string().describe('The error message to research') }, + async ({ error }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; + try { + return toolResponse(await perplexity.search(`How to fix this error: ${error}`)); + } catch (err) { + return toolError(err); + } + } +); // --- Content tools --- @@ -868,15 +782,14 @@ server.tool( 'summarize_url', { url: z.string().describe('The URL to summarize'), - focus: z.string().optional().describe('Focus area'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + focus: z.string().optional().describe('Focus area') }, - async ({ url, focus, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ url, focus }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { const f = focus ? ` Focus on: ${focus}` : ''; - return toolResponse(await p.instance.chat(`Summarize this webpage: ${url}${f}`)); + return toolResponse(await perplexity.search(`Summarize this webpage: ${url}${f}`)); } catch (err) { return toolError(err); } @@ -887,15 +800,14 @@ server.tool( 'generate_article', { topic: z.string().describe('Topic to write about'), - style: z.string().optional().describe('Writing style'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + style: z.string().optional().describe('Writing style') }, - async ({ topic, style, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ topic, style }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { const s = style ? ` in ${style} style` : ''; - return toolResponse(await p.instance.chat(`Write a comprehensive article about: ${topic}${s}`)); + return toolResponse(await perplexity.search(`Write a comprehensive article about: ${topic}${s}`)); } catch (err) { return toolError(err); } @@ -904,15 +816,12 @@ server.tool( server.tool( 'brainstorm', - { - topic: z.string().describe('Topic to brainstorm ideas for'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ topic, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + { topic: z.string().describe('Topic to brainstorm ideas for') }, + async ({ topic }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Brainstorm creative ideas for: ${topic}`)); + return toolResponse(await perplexity.search(`Brainstorm creative ideas for: ${topic}`)); } catch (err) { return toolError(err); } @@ -923,34 +832,90 @@ server.tool( 'analyze_document', { url: z.string().describe('URL of the document'), - question: z.string().optional().describe('Specific question'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + question: z.string().optional().describe('Specific question') }, - async ({ url, question, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ url, question }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { const q = question ? ` Answer: ${question}` : ''; - return toolResponse(await p.instance.chat(`Analyze this document: ${url}${q}`)); + return toolResponse(await perplexity.search(`Analyze this document: ${url}${q}`)); } catch (err) { return toolError(err); } } ); +server.tool( + 'analyze_image_url', + { + imageUrl: z.string().describe('URL of the image to analyze'), + focus: z.string().optional().describe('What to focus on in the analysis'), + provider: z.string().optional().describe('Which AI to use (chatgpt, claude, gemini). Default: chatgpt') + }, + async ({ imageUrl, focus, provider }) => { + try { + const useProvider = provider || 'chatgpt'; + const disabled = checkDisabled(useProvider); + if (disabled) return disabled; + + // Download image to temp file + const httpModule = imageUrl.startsWith('https') ? https : http; + const tmpDir = os.tmpdir(); + + const urlPath = new URL(imageUrl).pathname; + const urlExt = path.extname(urlPath) || '.png'; + const tmpFile = path.join(tmpDir, `proxima_img_${Date.now()}${urlExt}`); + + await new Promise((resolve, reject) => { + const file = fs.createWriteStream(tmpFile); + httpModule.get(imageUrl, (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + const redirectModule = response.headers.location.startsWith('https') ? https : http; + redirectModule.get(response.headers.location, (res) => { + res.pipe(file); + file.on('finish', () => { file.close(); resolve(); }); + }).on('error', reject); + return; + } + response.pipe(file); + file.on('finish', () => { file.close(); resolve(); }); + }).on('error', reject); + }); + + console.error(`[analyze_image_url] Downloaded to: ${tmpFile}`); + + const focusText = focus ? ` Focus on: ${focus}.` : ''; + const message = `Please analyze this image in detail.${focusText}`; + + const providers = { perplexity, chatgpt, claude, gemini }; + const result = await providers[useProvider].chatWithFile(message, tmpFile); + + try { fs.unlinkSync(tmpFile); } catch (e) { } + + return toolResponse({ + success: true, + provider: useProvider, + imageUrl, + analysis: result.response + }); + } catch (err) { + return toolError(err); + } + } +); server.tool( 'extract_data', { content: z.string().describe('Text or URL to extract data from'), - dataType: z.string().describe('What data to extract'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + dataType: z.string().describe('What data to extract') }, - async ({ content, dataType, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ content, dataType }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Extract ${dataType} from: ${content}`)); + return toolResponse(await perplexity.search(`Extract ${dataType} from: ${content}`)); } catch (err) { return toolError(err); } @@ -961,37 +926,62 @@ server.tool( 'writing_help', { request: z.string().describe('What writing help you need'), - content: z.string().optional().describe('Content to improve'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + content: z.string().optional().describe('Content to improve') }, - async ({ request, content, provider: providerName }) => { - const p = resolveProvider(providerName, 'general'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ request, content }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { const c = content ? `\n\nContent:\n${content}` : ''; - return toolResponse(await p.instance.chat(`${request}${c}`)); + return toolResponse(await perplexity.search(`${request}${c}`)); } catch (err) { return toolError(err); } } ); - - -// --- Analysis tools --- - - server.tool( - 'fact_check', + 'generate_image', + `Generate a single image via ChatGPT's built-in image generator. + +IMPORTANT RULES — follow strictly: +1. ALWAYS call new_conversation before generating an image to start a fresh chat. This prevents context bleed and stale image returns. +2. Generate ONE image at a time. Never call this tool in parallel. Wait for the result before generating the next image. +3. Be patient — this tool blocks for 30–90 seconds while the image generates. The tool will return automatically when done. Do NOT retry while waiting. +4. If the tool returns an error or empty result, call new_conversation and try once more with a revised prompt. + +NEW CONVERSATION FLOW: + Step 1: call new_conversation ← resets ChatGPT chat context + Step 2: call generate_image ← generates image in fresh chat + For multiple images: new_conversation → generate_image → new_conversation → generate_image → ... + +STYLE CONSISTENCY (multiple images): + - If you have a reference image (local file path), pass it via reference_image. ChatGPT will use it for style matching. + - If no reference image: describe the style very explicitly in every prompt (same color palette, same art style wording, same character description). + +PROMPT STRUCTURE — build prompts like this: + [subject], [action/pose], [setting/background], [lighting], [style], [quality modifiers] + Example: "a red fox sitting on a snowy hill, golden hour lighting, photorealistic, detailed fur, 4k" + - Be specific and descriptive. Vague prompts produce generic results. + - Include style if relevant (e.g. "pixel art", "oil painting", "anime", "photorealistic"). + - Include size/aspect ratio via the size parameter, not in the prompt text.`, { - claim: z.string().describe('The claim to verify'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + prompt: z.string().describe('Image description. Be specific: subject, setting, lighting, style, quality. Example: "a red fox on a snowy hill, golden hour, photorealistic, 4k"'), + style: z.string().optional().describe('Art style override (e.g. photorealistic, oil painting, pixel art, anime, cyberpunk, watercolor)'), + size: z.string().optional().describe('Aspect ratio hint: "square" (1:1), "landscape" (16:9), "portrait" (9:16)'), + reference_image: z.string().optional().describe('Absolute path to a local image file to use as style/content reference. ChatGPT will generate a new image matching the style of this reference.') }, - async ({ claim, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ prompt, style, size, reference_image }) => { + const disabled = checkDisabled('chatgpt'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Fact check with sources: ${claim}`)); + const styleStr = style ? `, ${style} style` : ''; + const sizeStr = size ? `, ${size} format` : ''; + const fullPrompt = `Generate an image with this description: ${prompt}${styleStr}${sizeStr}. Output only the image, no explanation.`; + if (reference_image && fs.existsSync(reference_image)) { + return toolResponse(await chatgpt.chatWithFile(fullPrompt, reference_image)); + } + return toolResponse(await chatgpt.chat(fullPrompt, false)); } catch (err) { return toolError(err); } @@ -999,38 +989,38 @@ server.tool( ); server.tool( - 'find_stats', + 'generate_image_prompt', { - topic: z.string().describe('Topic to find statistics about'), - year: z.string().optional().describe('Specific year'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + description: z.string().describe('What image you want'), + style: z.string().optional().describe('Art style') }, - async ({ topic, year, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ description, style }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const y = year ? ` for ${year}` : ''; - return toolResponse(await p.instance.chat(`Find statistics and data${y}: ${topic}`)); + const s = style ? ` in ${style} style` : ''; + return toolResponse(await perplexity.search(`Create a detailed AI image generation prompt for: ${description}${s}`)); } catch (err) { return toolError(err); } } ); +// --- Analysis tools --- + server.tool( - 'compare', + 'translate', { - item1: z.string().describe('First item to compare'), - item2: z.string().describe('Second item to compare'), - context: z.string().optional().describe('Context for comparison'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') + text: z.string().describe('Text to translate'), + targetLanguage: z.string().describe('Target language'), + sourceLanguage: z.string().optional().describe('Source language') }, - async ({ item1, item2, context, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + async ({ text, targetLanguage, sourceLanguage }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - const ctx = context ? ` for ${context}` : ''; - return toolResponse(await p.instance.chat(`Compare ${item1} vs ${item2}${ctx}`)); + const from = sourceLanguage ? ` from ${sourceLanguage}` : ''; + return toolResponse(await perplexity.search(`Translate${from} to ${targetLanguage}: ${text}`)); } catch (err) { return toolError(err); } @@ -1038,36 +1028,98 @@ server.tool( ); server.tool( - 'how_to', - { - task: z.string().describe('What to learn how to do'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ task, provider: providerName }) => { - const p = resolveProvider(providerName, 'research'); - if (!p) return toolResponse('No providers available. Enable at least one provider.'); + 'fact_check', + { claim: z.string().describe('The claim to verify') }, + async ({ claim }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; try { - return toolResponse(await p.instance.chat(`Step-by-step guide: How to ${task}`)); + return toolResponse(await perplexity.search(`Fact check with sources: ${claim}`)); } catch (err) { return toolError(err); } } ); -// --- Multi-AI provider tools --- - server.tool( - 'ask_chatgpt', + 'find_stats', { - message: z.string().describe('Message to send to ChatGPT'), - files: z.array(z.string()).optional().describe('Optional: file paths to include as context. Supports line ranges like "path/file.js:10-50". For large files, always specify relevant line ranges only.') + topic: z.string().describe('Topic to find statistics about'), + year: z.string().optional().describe('Specific year') }, - async ({ message, files }) => { + async ({ topic, year }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; + try { + const y = year ? ` for ${year}` : ''; + return toolResponse(await perplexity.search(`Find statistics and data${y}: ${topic}`)); + } catch (err) { + return toolError(err); + } + } +); + +server.tool( + 'compare', + { + item1: z.string().describe('First item to compare'), + item2: z.string().describe('Second item to compare'), + context: z.string().optional().describe('Context for comparison') + }, + async ({ item1, item2, context }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; + try { + const ctx = context ? ` for ${context}` : ''; + return toolResponse(await perplexity.search(`Compare ${item1} vs ${item2}${ctx}`)); + } catch (err) { + return toolError(err); + } + } +); + +server.tool( + 'how_to', + { task: z.string().describe('What to learn how to do') }, + async ({ task }) => { + const disabled = checkDisabled('perplexity'); + if (disabled) return disabled; + try { + return toolResponse(await perplexity.search(`Step-by-step guide: How to ${task}`)); + } catch (err) { + return toolError(err); + } + } +); + +// --- Multi-AI provider tools --- + +server.tool( + 'ask_chatgpt', + { + message: z.string().describe('Message to send to ChatGPT'), + files: z.array(z.string()).optional().describe('Optional: Array of file paths to upload as attachments') + }, + async ({ message, files }) => { const disabled = checkDisabled('chatgpt'); if (disabled) return disabled; try { - const fullMessage = buildMessageWithFiles(message, files); - return toolResponse(await chatgpt.chat(fullMessage)); + // If files provided, upload all then chat + if (files && files.length > 0) { + // Upload all files one by one + for (let i = 0; i < files.length; i++) { + if (i === files.length - 1) { + // Last file: upload + send message together + const result = await chatgpt.chatWithFile(message, files[i]); + return toolResponse(result.response); + } else { + // Not last: just upload + await chatgpt.uploadFile(files[i]); + } + } + } + // Otherwise send normal message + return toolResponse(await chatgpt.chat(message)); } catch (err) { return toolError(err); } @@ -1078,14 +1130,25 @@ server.tool( 'ask_claude', { message: z.string().describe('Message to send to Claude'), - files: z.array(z.string()).optional().describe('Optional: file paths to include as context. Supports line ranges like "path/file.js:10-50". For large files, always specify relevant line ranges only.') + files: z.array(z.string()).optional().describe('Optional: Array of file paths to upload as attachments') }, async ({ message, files }) => { const disabled = checkDisabled('claude'); if (disabled) return disabled; try { - const fullMessage = buildMessageWithFiles(message, files); - return toolResponse(await claude.chat(fullMessage)); + // If files provided, upload all then chat + if (files && files.length > 0) { + for (let i = 0; i < files.length; i++) { + if (i === files.length - 1) { + const result = await claude.chatWithFile(message, files[i]); + return toolResponse(result.response); + } else { + await claude.uploadFile(files[i]); + } + } + } + // Otherwise send normal message + return toolResponse(await claude.chat(message)); } catch (err) { return toolError(err); } @@ -1096,14 +1159,25 @@ server.tool( 'ask_gemini', { message: z.string().describe('Message to send to Gemini'), - files: z.array(z.string()).optional().describe('Optional: file paths to include as context. Supports line ranges like "path/file.js:10-50". For large files, always specify relevant line ranges only.') + files: z.array(z.string()).optional().describe('Optional: Array of file paths to upload as attachments') }, async ({ message, files }) => { const disabled = checkDisabled('gemini'); if (disabled) return disabled; try { - const fullMessage = buildMessageWithFiles(message, files); - return toolResponse(await gemini.chat(fullMessage)); + // If files provided, upload all then chat + if (files && files.length > 0) { + for (let i = 0; i < files.length; i++) { + if (i === files.length - 1) { + const result = await gemini.chatWithFile(message, files[i]); + return toolResponse(result.response); + } else { + await gemini.uploadFile(files[i]); + } + } + } + // Otherwise send normal message + return toolResponse(await gemini.chat(message)); } catch (err) { return toolError(err); } @@ -1123,21 +1197,13 @@ server.tool( const tasks = []; const names = []; - console.error('[ask_all_ais] Sending to all providers with staggered start (prevents UI freeze)...'); + console.error('[ask_all_ais] Sending to all providers in parallel...'); - // Helper: staggered delay to prevent Electron main process overload - // Each provider starts 2s apart but all run concurrently via Promise.all - const STAGGER_MS = 2000; - let providerIndex = 0; - const staggerDelay = (ms) => new Promise(r => setTimeout(r, ms)); - - // Build staggered parallel tasks + // Send to all providers in PARALLEL if (enabled.has('perplexity')) { - const delay = providerIndex++ * STAGGER_MS; names.push('perplexity'); tasks.push( (async () => { - if (delay > 0) await staggerDelay(delay); try { return await perplexity.search(fullMessage); } catch (e) { @@ -1147,11 +1213,9 @@ server.tool( ); } if (enabled.has('chatgpt')) { - const delay = providerIndex++ * STAGGER_MS; names.push('chatgpt'); tasks.push( (async () => { - if (delay > 0) await staggerDelay(delay); try { return await chatgpt.chat(fullMessage); } catch (e) { @@ -1161,11 +1225,9 @@ server.tool( ); } if (enabled.has('claude')) { - const delay = providerIndex++ * STAGGER_MS; names.push('claude'); tasks.push( (async () => { - if (delay > 0) await staggerDelay(delay); try { return await claude.chat(fullMessage); } catch (e) { @@ -1175,11 +1237,9 @@ server.tool( ); } if (enabled.has('gemini')) { - const delay = providerIndex++ * STAGGER_MS; names.push('gemini'); tasks.push( (async () => { - if (delay > 0) await staggerDelay(delay); try { return await gemini.chat(fullMessage); } catch (e) { @@ -1190,7 +1250,7 @@ server.tool( } // Wait for ALL to complete - typing detection is handled inside chat() - console.error('[ask_all_ais] Waiting for all providers to complete...'); + console.error('[ask_all_ais] Waiting for all providers to complete typing...'); const results = await Promise.all(tasks); console.error('[ask_all_ais] All providers completed'); @@ -1233,14 +1293,11 @@ server.tool( const results = {}; const tasks = []; - const staggerDelay = (ms) => new Promise(r => setTimeout(r, ms)); - const STAGGER_MS = 2000; - let idx = 0; - if (useProviders.includes('perplexity')) { const d = idx++ * STAGGER_MS; tasks.push((async () => { if (d > 0) await staggerDelay(d); try { results.perplexity = await perplexity.search(fullQuestion); } catch(e) { results.perplexity = { error: e.message }; } })()); } - if (useProviders.includes('chatgpt')) { const d = idx++ * STAGGER_MS; tasks.push((async () => { if (d > 0) await staggerDelay(d); try { results.chatgpt = await chatgpt.chat(fullQuestion); } catch(e) { results.chatgpt = { error: e.message }; } })()); } - if (useProviders.includes('claude')) { const d = idx++ * STAGGER_MS; tasks.push((async () => { if (d > 0) await staggerDelay(d); try { results.claude = await claude.chat(fullQuestion); } catch(e) { results.claude = { error: e.message }; } })()); } - if (useProviders.includes('gemini')) { const d = idx++ * STAGGER_MS; tasks.push((async () => { if (d > 0) await staggerDelay(d); try { results.gemini = await gemini.chat(fullQuestion); } catch(e) { results.gemini = { error: e.message }; } })()); } + if (useProviders.includes('perplexity')) tasks.push(perplexity.search(fullQuestion).then(r => results.perplexity = r).catch(e => results.perplexity = { error: e.message })); + if (useProviders.includes('chatgpt')) tasks.push(chatgpt.chat(fullQuestion).then(r => results.chatgpt = r).catch(e => results.chatgpt = { error: e.message })); + if (useProviders.includes('claude')) tasks.push(claude.chat(fullQuestion).then(r => results.claude = r).catch(e => results.claude = { error: e.message })); + if (useProviders.includes('gemini')) tasks.push(gemini.chat(fullQuestion).then(r => results.gemini = r).catch(e => results.gemini = { error: e.message })); await Promise.all(tasks); @@ -1289,22 +1346,16 @@ server.tool( } ); - - -// --- ask_perplexity (was missing!) --- - server.tool( - 'ask_perplexity', - { - message: z.string().describe('Message to send to Perplexity (best for web search + citations)'), - files: z.array(z.string()).optional().describe('Optional: file paths to include as context. Supports line ranges like "path/file.js:10-50". For large files, always specify relevant line ranges only.') - }, - async ({ message, files }) => { - const disabled = checkDisabled('perplexity'); - if (disabled) return disabled; + 'router_stats', + {}, + async () => { try { - const fullMessage = buildMessageWithFiles(message, files); - return toolResponse(await perplexity.chat(fullMessage)); + return toolResponse({ + success: true, + stats: router.getStats(), + timestamp: new Date().toISOString() + }); } catch (err) { return toolError(err); } @@ -1329,685 +1380,6 @@ server.tool( } ); -// --- chain_query: Sequential multi-AI pipeline --- - -server.tool( - 'chain_query', - { - steps: z.array(z.object({ - provider: z.string().describe('AI provider: chatgpt, claude, gemini, perplexity'), - prompt: z.string().describe('Prompt for this step. Use {previous} to inject previous step output.') - })).describe('Array of pipeline steps. Each step receives previous output via {previous} placeholder.'), - initialContext: z.string().optional().describe('Optional initial context to pass as {previous} to first step') - }, - async ({ steps, initialContext }) => { - try { - const providers = { perplexity, chatgpt, claude, gemini }; - const results = []; - let previousOutput = initialContext || ''; - - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const providerName = step.provider.toLowerCase(); - const provider = providers[providerName]; - - if (!provider) { - results.push({ step: i + 1, provider: providerName, error: `Unknown provider: ${providerName}` }); - continue; - } - - const disabled = checkDisabled(providerName); - if (disabled) { - results.push({ step: i + 1, provider: providerName, error: `${providerName} is disabled` }); - continue; - } - - // Replace {previous} placeholder with output from last step - const prompt = step.prompt.replace(/\{previous\}/gi, previousOutput); - - try { - const response = await provider.chat(prompt); - previousOutput = response; - results.push({ - step: i + 1, - provider: providerName, - response: response - }); - } catch (err) { - results.push({ step: i + 1, provider: providerName, error: err.message }); - // Don't break chain — next step gets empty previous - previousOutput = `[Step ${i + 1} failed: ${err.message}]`; - } - } - - return toolResponse({ - success: true, - totalSteps: steps.length, - completedSteps: results.filter(r => !r.error).length, - finalOutput: previousOutput, - pipeline: results, - timestamp: new Date().toISOString() - }); - } catch (err) { - return toolError(err); - } - } -); - -// --- Smart Provider Selection Helper --- -function pickBestProvider(taskType) { - const enabled = getEnabledProviders(); - const providers = { perplexity, chatgpt, claude, gemini }; - - // Priority order based on task type - const priorities = { - coding: ['claude', 'chatgpt', 'gemini', 'perplexity'], - research: ['perplexity', 'gemini', 'chatgpt', 'claude'], - general: ['claude', 'chatgpt', 'gemini', 'perplexity'], - review: ['claude', 'chatgpt', 'gemini', 'perplexity'] - }; - - const order = priorities[taskType] || priorities.general; - for (const name of order) { - if (enabled.has(name) && providers[name]) { - return { name, instance: providers[name] }; - } - } - return null; -} - -// --- Dynamic Provider Resolution --- -function resolveProvider(providerName, taskType) { - const providers = { perplexity, chatgpt, claude, gemini }; - - if (providerName) { - const name = providerName.toLowerCase(); - if (!providers[name]) return null; - const enabled = getEnabledProviders(); - if (!enabled.has(name)) return null; - return { name, instance: providers[name] }; - } - - // No preference — auto-select based on task type - return pickBestProvider(taskType || 'general'); -} - -// --- solve: One-shot problem solver --- - -server.tool( - 'solve', - { - task: z.string().describe('What to solve — coding task, bug, feature, anything'), - files: z.array(z.string()).optional().describe('Optional: file paths for context'), - language: z.string().optional().describe('Programming language if relevant'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') - }, - async ({ task, files, language, provider: providerName }) => { - try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - let context = ''; - if (files && files.length > 0) { - context = readFileContents(files) || ''; - } - - const langHint = language ? ` (Language: ${language})` : ''; - const prompt = `You are a senior software engineer. Solve this task completely.${langHint} - -TASK: ${task} -${context ? `\nCODE CONTEXT:\n${context}` : ''} - -Provide: -1. Brief analysis of the problem -2. Complete working solution with full code -3. Explanation of key decisions -4. Any edge cases or gotchas to watch for - -Be thorough and production-ready. Do not use placeholder code.`; - - const response = await provider.instance.chat(prompt); - return toolResponse(response); - } catch (err) { - return toolError(err); - } - } -); - -// --- verify: Answer confidence checker --- - -// --- debate: Multi-provider topic debate --- - -server.tool( - 'debate', - { - topic: z.string().describe('Topic or question to debate'), - sides: z.number().optional().describe('Number of perspectives to gather (default: 2)') - }, - async ({ topic, sides }) => { - try { - const enabled = getEnabledProviders(); - const allProviders = { perplexity, chatgpt, claude, gemini }; - const numSides = Math.min(sides || 2, enabled.size); - - if (enabled.size < 2) { - // Single provider — generate both sides - const provider = pickBestProvider('general'); - if (!provider) return toolResponse('No providers enabled'); - const response = await provider.instance.chat( - `Debate this topic from ${numSides} different perspectives. For each perspective, present strong arguments with evidence.\n\nTopic: ${topic}\n\nFormat each perspective as:\n## Perspective [N]: [Position]\n- Key arguments\n- Supporting evidence\n\nThen provide a balanced conclusion.` - ); - return toolResponse(response); - } - - // Multi-provider — each AI argues a different side - const providerNames = [...enabled].filter(n => allProviders[n]).slice(0, numSides); - const stances = ['FOR / supportive', 'AGAINST / critical', 'NEUTRAL / analytical', 'ALTERNATIVE / unconventional']; - const results = {}; - - const promises = providerNames.map(async (name, i) => { - try { - const stance = stances[i] || `Perspective ${i + 1}`; - const response = await allProviders[name].chat( - `You are debating the following topic. Your assigned position is: ${stance}.\n\nTopic: ${topic}\n\nPresent your strongest arguments for this position. Be persuasive and use evidence. Do NOT present the other side.` - ); - results[name] = { stance, response }; - } catch (e) { - results[name] = { stance: stances[i], error: e.message }; - } - }); - - await Promise.all(promises); - return toolResponse(results); - } catch (err) { - return toolError(err); - } - } -); - -// --- security_audit: Code security vulnerability scanner --- - -server.tool( - 'security_audit', - { - code: z.string().optional().describe('Code to audit for security vulnerabilities'), - files: z.array(z.string()).optional().describe('Optional: file paths to audit'), - language: z.string().optional().describe('Programming language'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') - }, - async ({ code, files, language, provider: providerName }) => { - try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - const lang = language ? ` (${language})` : ''; - const codeContent = code || ''; - const fileContent = readFileContents(files); - const fullCode = fileContent ? `${fileContent}\n\n${codeContent}` : codeContent; - - if (!fullCode.trim()) return toolResponse('No code provided. Pass code or files parameter.'); - - const prompt = `You are a senior security engineer. Perform a thorough security audit of this code${lang}. - -CODE: -${fullCode} - -Check for ALL of the following: -1. **Injection vulnerabilities** (SQL, XSS, command injection, LDAP, etc.) -2. **Authentication/Authorization flaws** (broken auth, privilege escalation, insecure tokens) -3. **Data exposure** (hardcoded secrets, PII leaks, insecure logging) -4. **Input validation** (missing sanitization, type confusion, buffer overflow) -5. **Cryptographic issues** (weak algorithms, improper key management) -6. **Configuration problems** (debug mode, CORS, insecure defaults) -7. **Dependency risks** (known vulnerable packages) - -For each issue found: -- **Severity**: CRITICAL / HIGH / MEDIUM / LOW -- **Location**: Line or function -- **Description**: What the vulnerability is -- **Fix**: Exact code fix - -End with a security score (0-100) and summary.`; - - const response = await provider.instance.chat(prompt); - return toolResponse(response); - } catch (err) { - return toolError(err); - } - } -); - - -server.tool( - 'verify', - { - question: z.string().describe('Question or claim to verify'), - providers: z.array(z.string()).optional().describe('Optional: specific providers to use for verification') - }, - async ({ question, providers: requestedProviders }) => { - try { - const enabled = getEnabledProviders(); - const allProviders = { perplexity, chatgpt, claude, gemini }; - - // Determine which providers to use - let targetProviders = requestedProviders - ? requestedProviders.filter(p => enabled.has(p)) - : [...enabled]; - - if (targetProviders.length === 0) return toolResponse('No providers enabled'); - - const prompt = `Answer this question thoroughly. At the end, rate your confidence (0-100%) and list any counter-arguments or caveats: - -${question} - -Format: -ANSWER: [your detailed answer] -CONFIDENCE: [0-100%] -CAVEATS: [any counter-arguments, edge cases, or uncertainties]`; - - if (targetProviders.length === 1) { - // Single provider — self-verification - const p = allProviders[targetProviders[0]]; - const response = await p.chat(prompt); - return toolResponse(`=== ${targetProviders[0].toUpperCase()} ===\n${response}`); - } - - // Multiple providers — cross-verify - const results = {}; - for (const name of targetProviders) { - const p = allProviders[name]; - if (p) { - try { - results[name] = await p.chat(prompt); - } catch (e) { - results[name] = `Error: ${e.message}`; - } - } - } - - let output = ''; - for (const [name, resp] of Object.entries(results)) { - output += `\n=== ${name.toUpperCase()} ===\n${resp}\n`; - } - return toolResponse(output.trim()); - } catch (err) { - return toolError(err); - } - } -); - -// --- fix_error: Smart error fixer --- - -server.tool( - 'fix_error', - { - error: z.string().describe('Error message or stack trace'), - file: z.string().optional().describe('File path where the error occurs'), - context: z.string().optional().describe('Additional context about what you were doing'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') - }, - async ({ error, file, context: ctx, provider: providerName }) => { - try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - let fileContent = ''; - if (file) { - fileContent = readFileContents([file]) || ''; - } - - const prompt = `You are a debugging expert. Fix this error completely. - -ERROR: -${error} -${ctx ? `\nCONTEXT: ${ctx}` : ''} -${fileContent ? `\nSOURCE CODE:\n${fileContent}` : ''} - -Provide: -1. ROOT CAUSE: Why this error happens (be specific) -2. FIX: The exact code changes needed (show before/after) -3. PREVENTION: How to prevent this in the future -4. FULL FIXED CODE: Complete corrected code ready to use - -Do not give vague advice. Give the exact fix.`; - - const response = await provider.instance.chat(prompt); - return toolResponse(response); - } catch (err) { - return toolError(err); - } - } -); - -// --- build_architecture: Project architecture planner --- - -server.tool( - 'build_architecture', - { - description: z.string().describe('What you want to build'), - constraints: z.string().optional().describe('Tech constraints (e.g., "must use Next.js, PostgreSQL")'), - scale: z.string().optional().describe('Expected scale (e.g., "10k users", "enterprise")'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') - }, - async ({ description, constraints, scale, provider: providerName }) => { - try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - const prompt = `You are a senior software architect. Design a complete architecture for this project. - -PROJECT: ${description} -${constraints ? `CONSTRAINTS: ${constraints}` : ''} -${scale ? `SCALE: ${scale}` : ''} - -Provide a complete, production-ready architecture: - -1. TECH STACK: Every technology with justification -2. FOLDER STRUCTURE: Complete directory tree with file descriptions -3. DATABASE SCHEMA: Full schema with tables, columns, types, relations (SQL or NoSQL) -4. API ENDPOINTS: Complete REST/GraphQL API design with methods, paths, request/response -5. COMPONENT TREE: Frontend component hierarchy -6. AUTH & SECURITY: Authentication strategy, security measures -7. DEPLOYMENT: Hosting, CI/CD, environment setup -8. THIRD-PARTY SERVICES: Any external APIs or services needed - -Be exhaustive. A developer should be able to start coding immediately from this blueprint.`; - - const response = await provider.instance.chat(prompt); - return toolResponse(response); - } catch (err) { - return toolError(err); - } - } -); - -// --- write_tests: Auto test generator --- - -server.tool( - 'write_tests', - { - file: z.string().describe('File path to generate tests for'), - framework: z.string().optional().describe('Test framework (jest, vitest, mocha, pytest). Default: auto-detect'), - focus: z.string().optional().describe('Focus area: unit, integration, edge-cases, all'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') - }, - async ({ file, framework, focus, provider: providerName }) => { - try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - const fileContent = readFileContents([file]); - if (!fileContent) { - return toolResponse('Could not read file: ' + file); - } - - const fw = framework || 'auto-detect from the code'; - const focusArea = focus || 'comprehensive (unit + edge cases)'; - - const prompt = `You are a testing expert. Write complete tests for this code. - -${fileContent} - -TEST FRAMEWORK: ${fw} -FOCUS: ${focusArea} - -Requirements: -1. Cover ALL exported functions/classes/methods -2. Include happy path + edge cases + error scenarios -3. Use descriptive test names that explain what's being tested -4. Include setup/teardown if needed -5. Mock external dependencies properly -6. Aim for high coverage - -Return ONLY the complete test file code, ready to save and run.`; - - const response = await provider.instance.chat(prompt); - return toolResponse(response); - } catch (err) { - return toolError(err); - } - } -); - -// --- explain_error: Error explainer in simple terms --- - -server.tool( - 'explain_error', - { - error: z.string().describe('Error message or stack trace to explain'), - context: z.string().optional().describe('What you were doing when the error occurred'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select') - }, - async ({ error, context: ctx, provider: providerName }) => { - try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('general'); - if (!provider) return toolResponse('No providers enabled'); - - const prompt = `Explain this error in simple terms and provide step-by-step fix instructions. - -ERROR: -${error} -${ctx ? `\nCONTEXT: ${ctx}` : ''} - -Provide: -1. WHAT HAPPENED: Plain English explanation (no jargon) -2. WHY: The most common causes of this error (ranked by likelihood) -3. HOW TO FIX: Step-by-step fix instructions for each cause -4. QUICK FIX: The single most likely fix in one code snippet - -Be practical and specific. Not theory — actual commands and code.`; - - const response = await provider.instance.chat(prompt); - return toolResponse(response); - } catch (err) { - return toolError(err); - } - } -); - -// --- convert_code: Code language/framework converter --- - -server.tool( - 'convert_code', - { - file: z.string().optional().describe('File path to convert'), - code: z.string().optional().describe('Code snippet to convert (if no file)'), - from: z.string().optional().describe('Source language/framework (auto-detected if not specified)'), - to: z.string().describe('Target language/framework (e.g., "TypeScript", "Python/FastAPI", "Vue 3")'), - provider: z.string().optional().describe('AI provider to use: chatgpt, claude, gemini, perplexity. Default: auto-select best for coding') - }, - async ({ file, code, from, to, provider: providerName }) => { - try { - const provider = providerName ? resolveProvider(providerName) : pickBestProvider('coding'); - if (!provider) return toolResponse('No providers enabled'); - - let sourceCode = code || ''; - if (file) { - sourceCode = readFileContents([file]) || sourceCode; - } - if (!sourceCode) { - return toolResponse('No code provided. Use file path or code parameter.'); - } - - const fromHint = from ? `from ${from} ` : ''; - - const prompt = `Convert this code ${fromHint}to ${to}. Preserve ALL functionality. - -SOURCE CODE: -${sourceCode} - -Requirements: -1. Maintain the exact same logic and behavior -2. Use idiomatic patterns for ${to} (not a literal translation) -3. Handle framework-specific differences (routing, state, lifecycle, etc.) -4. Add necessary imports/dependencies -5. Include comments where the conversion required significant changes - -Return the complete converted code, ready to use.`; - - const response = await provider.instance.chat(prompt); - return toolResponse(response); - } catch (err) { - return toolError(err); - } - } -); - -// --- ask_selected: Ask specific providers (user picks which ones) --- - -server.tool( - 'ask_selected', - { - message: z.string().describe('Message to send to selected providers'), - providers: z.array(z.string()).describe('Which providers to ask: ["chatgpt", "claude", "gemini", "perplexity"]'), - files: z.array(z.string()).optional().describe('Optional: file paths to include as context') - }, - async ({ message, providers: selectedProviders, files }) => { - try { - const enabled = getEnabledProviders(); - const allProviders = { perplexity, chatgpt, claude, gemini }; - - let fileContext = ''; - if (files && files.length > 0) { - fileContext = readFileContents(files) || ''; - } - - const fullMessage = fileContext ? `${fileContext}\n\n${message}` : message; - const results = {}; - - for (const name of selectedProviders) { - if (!enabled.has(name)) { - results[name] = { error: `${name} is not enabled` }; - continue; - } - const p = allProviders[name]; - if (!p) { - results[name] = { error: `Unknown provider: ${name}` }; - continue; - } - try { - const response = await p.chat(fullMessage); - results[name] = { success: true, response }; - } catch (e) { - results[name] = { error: e.message }; - } - } - - // Build clean text output - let output = ''; - for (const [name, data] of Object.entries(results)) { - output += `\n=== ${name.toUpperCase()} ===\n`; - if (data.error) { - output += `Error: ${data.error}\n`; - } else { - output += data.response + '\n'; - } - } - return toolResponse(output.trim()); - } catch (err) { - return toolError(err); - } - } -); - -// --- conversation_export: Extract full conversation from currently open chat --- - -server.tool( - 'conversation_export', - { - provider: z.string().optional().describe('Provider to export from: chatgpt, claude, gemini, perplexity. Default: all enabled') - }, - async ({ provider }) => { - try { - const enabled = getEnabledProviders(); - const targetProviders = provider - ? [provider.toLowerCase()] - : [...enabled]; - - const extractionPrompt = `Tell our entire conversation history in this from the very beginning to the very end. - -Extract everything and present it in the following structure: - -PROJECT - -What are we building? Include the name, the core idea, the purpose, and the problem it solves. - -DECISIONS MADE - -Everything that has been finalized. Tech stack, architecture, tools, libraries, design choices, folder structure, database schema, API design — anything that was decided and agreed upon. - -REQUIREMENTS - -Every single requirement the user mentioned. Features, constraints, what the user wants and does not want, priorities, tone, style preferences — everything. - -WORK DONE SO FAR - -What has already been built, written, or implemented. Include file names, code written, components created, APIs built — be specific. - -IDEAS AND DIRECTION - -Any ideas, suggestions, directions, or approaches that were discussed even if not finalized. Include things that were considered and rejected too, with the reason why. - -PENDING AND NEXT STEPS - -Everything that still needs to be done. What was the last thing discussed? What was about to happen next? - -KEY DETAILS AND CONTEXT - -Any important user preferences, specific instructions, edge cases, constraints, or anything unique about this project that a new AI picking this up must know. - -Be exhaustive. Do not summarize loosely. A coding AI is going to read this and start building without asking the user a single question — so include everything.`; - - const providers = { perplexity, chatgpt, claude, gemini }; - const results = {}; - - for (const prov of targetProviders) { - if (!enabled.has(prov)) { - results[prov] = { error: prov + ' is not enabled' }; - continue; - } - - try { - console.error('[conversation_export] Extracting from ' + prov + ' (forceDOM)...'); - - // forceDOM: true — skips API inject, types into currently open conversation - // Same IPC flow as all tools, just with forceDOM flag - await ipcClient.send('sendMessage', prov, { message: extractionPrompt, forceDOM: true }); - - // Wait for response — same getResponseWithTyping as every other tool - const result = await ipcClient.send('getResponseWithTyping', prov, {}); - const response = result.response || 'No response captured'; - - results[prov] = { - success: true, - provider: prov, - export: response, - exportedAt: new Date().toISOString() - }; - console.error('[conversation_export] ' + prov + ' export complete'); - } catch (err) { - results[prov] = { error: err.message }; - } - } - - // Build clean text output (not JSON — so newlines render properly) - let output = ''; - for (const [prov, data] of Object.entries(results)) { - output += `\n=== ${prov.toUpperCase()} ===\n`; - if (data.error) { - output += `Error: ${data.error}\n`; - } else { - output += data.export + '\n'; - } - } - - return toolResponse(output.trim()); - } catch (err) { - return toolError(err); - } - } -); - server.tool( 'clear_cache', {}, @@ -2033,17 +1405,31 @@ server.tool( question: z.string().optional().describe('Specific question about the file'), provider: z.string().optional().describe('Which AI to use (chatgpt, claude, gemini, perplexity). Default: claude') }, - async ({ filePath, question, provider: providerName }) => { + async ({ filePath, question, provider }) => { try { - const p = resolveProvider(providerName || 'claude', 'coding'); - if (!p) return toolResponse(`Provider '${providerName || 'claude'}' is not available. Enable it in Agent Hub.`); + const useProvider = provider || 'claude'; + const disabled = checkDisabled(useProvider); + if (disabled) return disabled; // Check if file is an image (binary) - use upload method instead of text read const ext = path.extname(filePath).toLowerCase(); const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']; + const providers = { perplexity, chatgpt, claude, gemini }; if (imageExtensions.includes(ext)) { - return toolResponse({ success: false, error: 'Image analysis is not available yet. This tool currently supports text/code files only.' }); + // Image file: upload to provider and ask about it + const imageQuestion = question + ? `Please analyze this image and answer: ${question}` + : `Please analyze this image and describe its contents, purpose, and any notable aspects.`; + + const result = await providers[useProvider].chatWithFile(imageQuestion, filePath); + return toolResponse({ + success: true, + provider: useProvider, + filePath, + type: 'image', + analysis: result.response + }); } // Text file: read and paste as context @@ -2056,11 +1442,11 @@ server.tool( ? `${fileContent}\n\nPlease analyze this file and answer: ${question}` : `${fileContent}\n\nPlease analyze this file and explain its contents, purpose, and any notable aspects.`; - const response = await p.instance.chat(message); + const response = await providers[useProvider].chat(message); return toolResponse({ success: true, - provider: p.name, + provider: useProvider, filePath, analysis: response }); @@ -2077,10 +1463,11 @@ server.tool( focus: z.string().optional().describe('What to focus on (bugs, performance, security, style)'), provider: z.string().optional().describe('Which AI to use. Default: claude') }, - async ({ filePath, focus, provider: providerName }) => { + async ({ filePath, focus, provider }) => { try { - const p = resolveProvider(providerName || 'claude', 'coding'); - if (!p) return toolResponse(`Provider '${providerName || 'claude'}' is not available. Enable it in Agent Hub.`); + const useProvider = provider || 'claude'; + const disabled = checkDisabled(useProvider); + if (disabled) return disabled; const fileContent = readFileContents([filePath]); if (!fileContent) { @@ -2090,11 +1477,12 @@ server.tool( const focusText = focus ? ` Focus on: ${focus}.` : ''; const message = `${fileContent}\n\nPlease review this code file.${focusText} Identify issues, suggest improvements, and follow best practices.`; - const response = await p.instance.chat(message); + const providers = { perplexity, chatgpt, claude, gemini }; + const response = await providers[useProvider].chat(message); return toolResponse({ success: true, - provider: p.name, + provider: useProvider, filePath, review: response }); @@ -2163,6 +1551,50 @@ server.tool( } ); +// Tool to check typing status +server.tool( + 'get_typing_status', + { + provider: z.string().optional().describe('Provider to check (chatgpt, claude, perplexity, gemini). If not specified, checks all.') + }, + async ({ provider }) => { + try { + const enabled = getEnabledProviders(); + const results = {}; + + if (provider) { + // Check specific provider + if (!enabled.has(provider)) { + return toolResponse({ error: `${provider} is not enabled` }); + } + const providers = { perplexity, chatgpt, claude, gemini }; + const p = providers[provider]; + if (p) { + const status = await p.getTypingStatus(); + return toolResponse({ [provider]: status }); + } + } else { + // Check all enabled providers + if (enabled.has('chatgpt')) { + results.chatgpt = await chatgpt.getTypingStatus(); + } + if (enabled.has('claude')) { + results.claude = await claude.getTypingStatus(); + } + if (enabled.has('perplexity')) { + results.perplexity = await perplexity.getTypingStatus(); + } + if (enabled.has('gemini')) { + results.gemini = await gemini.getTypingStatus(); + } + } + + return toolResponse(results); + } catch (err) { + return toolError(err); + } + } +); // --- Resources (MCP compat) --- @@ -2178,7 +1610,7 @@ server.resource( mimeType: 'application/json', text: JSON.stringify({ server: 'Proxima MCP Server', - version: '4.1.0', + version: '3.0.0', enabledProviders: Array.from(enabled), connected: ipcClient.connected }, null, 2)