diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 24b09e9..b1546c2 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -26,8 +26,26 @@ itself rather than reconstructing it at tag time. Purely local setups (`npx opendia`, Claude Desktop over stdio, the browser extension) are unaffected and need no token. (#55) +- **The `prompts/*` workflow surface is gone.** `prompts/list`, `prompts/get` and + the six workflow prompts (`post_to_social`, `post_selected_quote`, + `research_workflow`, `analyze_browsing_session`, `organize_tabs`, `fill_form`) + have been removed. They were unreachable in practice: the server never declared + a `prompts` capability during `initialize`, and `prompts/get` replied with + `{ content: [...] }` where the spec requires `{ messages: [...] }`, so + spec-compliant clients never called them. Tools are unaffected. + ### Security +- **`--token=` with an empty value disabled authentication entirely.** The flag was + matched with `startsWith('--token=')`, so a bare `--token=` produced an empty + token, and the request guard treated an empty token as "no auth configured" and + waved every request through. The startup banner was gated the same way, so + nothing was printed — `--tunnel --token=` published an unauthenticated + browser-control endpoint with no visible sign, defeating the boundary added in + #55. `--http-host=` had the same bug and bound all interfaces. Empty flag values + are now a startup error, and invalid `--port` values fail with a readable message + instead of an unhandled rejection. + - Bind the extension control channel to loopback and gate its handshake. The WebSocket server listened on all interfaces, and because the connection handler hands the extension slot to whoever connects last, any reachable peer could evict @@ -44,6 +62,32 @@ itself rather than reconstructing it at tag time. stopgap — removal is tracked in #58. (#56) - Bump `node-forge` to 1.4.0 and `brace-expansion` to 1.1.15. (#43) +### Fixed + +- **Tools no longer report failures to the model as success.** `get_history` + rendered a browser API failure as "No history items found", `get_selected_text` + rendered four distinct failures as "No text selected", `page_navigate` claimed + success when its wait condition had timed out, and the research workflow claimed + it had bookmarked a page after the bookmark call failed. Each now surfaces the + real error. The genuine empty cases still report normally. +- **A dropped connection no longer executes tools anyway.** The extension's + connection helper resolved without waiting for the socket to open and swallowed + connection failures, so a tool call proceeded even when the server was + unreachable — `tab_close` and `element_click` ran against the browser while the + reply was written to a socket that was still connecting and discarded. The model + saw only a generic "Tool call timeout" and would reasonably retry a side effect. + Connections now resolve on open and fail loudly, and Chrome reuses a live socket + instead of replacing the one the request arrived on. + +### Removed + +- The enhanced-pattern matching tier in the content script. It had never executed: + its guard referenced an identifier declared nowhere in the codebase, so it threw + on every call and the failure was swallowed, silently degrading `page_analyze` to + a viewport scan. The legacy single-phase analysis engine went with it — reaching + it required a `phase` value that both tool schemas forbid. `page_analyze` output + is unchanged, because none of the removed code could run. + ### Maintenance - Dependency bumps: `ws` (#52), `web-ext` (#48), `actions/checkout` 4 → 7 (#46), diff --git a/opendia-extension/build.js b/opendia-extension/build.js index 084aa12..5198cd3 100644 --- a/opendia-extension/build.js +++ b/opendia-extension/build.js @@ -18,9 +18,6 @@ async function buildForBrowser(browser) { if (await fs.pathExists('logo.mp4')) { await fs.copy('logo.mp4', path.join(buildDir, 'logo.mp4')); } - if (await fs.pathExists('logo.webm')) { - await fs.copy('logo.webm', path.join(buildDir, 'logo.webm')); - } // Copy browser-specific manifest await fs.copy( @@ -180,7 +177,11 @@ if (require.main === module) { buildForBrowser('firefox'); break; case 'validate': - validateAllBuilds(); + // Exit non-zero on failure, or the CI "Validate builds" step passes + // whatever validateAllBuilds() reports. + validateAllBuilds().then((ok) => { + if (!ok) process.exit(1); + }); break; case 'package': buildAll().then(() => createPackages()); diff --git a/opendia-extension/src/background/background.js b/opendia-extension/src/background/background.js index e390c29..8191db5 100644 --- a/opendia-extension/src/background/background.js +++ b/opendia-extension/src/background/background.js @@ -40,11 +40,43 @@ class ConnectionManager { this.isFirefox = browserInfo.isFirefox; } + // Resolves once the socket is usable, so callers can't send on a CONNECTING + // socket. Uses addEventListener so the onopen/onerror/onclose handlers + // assigned in createConnection stay intact. + waitForSocketOpen(socket, timeoutMs = 5000) { + if (socket.readyState === WebSocket.OPEN) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + socket.removeEventListener('open', onOpen); + socket.removeEventListener('error', onFail); + socket.removeEventListener('close', onFail); + }; + const onOpen = () => { cleanup(); resolve(); }; + const onFail = () => { + cleanup(); + reject(new Error(`Could not reach the OpenDia server at ${MCP_SERVER_URL}`)); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out connecting to ${MCP_SERVER_URL} after ${timeoutMs}ms`)); + }, timeoutMs); + + socket.addEventListener('open', onOpen); + socket.addEventListener('error', onFail); + socket.addEventListener('close', onFail); + }); + } + async connect() { if (this.isServiceWorker) { - // Chrome MV3: Create fresh connection for each operation - console.log('🔧 Chrome MV3: Creating temporary connection'); - await this.createConnection(); + // Reuse a live socket: reconnecting per operation replaced the socket the + // request arrived on, so the reply was written to a CONNECTING socket. + if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) { + console.log('🔧 Chrome MV3: Creating temporary connection'); + await this.createConnection(); + } } else { // Firefox MV2: Maintain persistent connection if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) { @@ -61,7 +93,10 @@ class ConnectionManager { // Try port discovery if using default URL or if connection failed if (MCP_SERVER_URL === 'ws://localhost:5555' || this.reconnectAttempts > 2) { await this.discoverServerPorts(); - this.reconnectAttempts = 0; // Reset attempts after discovery + // No reset here: discovery finding a port is not evidence the + // connection succeeded. Resetting made the counter oscillate 0->3->0, + // so backoff never grew and the give-up guard was unreachable. The + // real reset lives in onopen. } console.log('🔗 Connecting to MCP server at', MCP_SERVER_URL); @@ -115,12 +150,19 @@ class ConnectionManager { console.log('⚠️ MCP WebSocket error:', error); this.reconnectAttempts++; }; - + + await this.waitForSocketOpen(this.mcpSocket); + } catch (error) { console.error('Connection failed:', error); if (!this.isServiceWorker) { this.scheduleReconnect(); } + // Rethrow so ensureConnection() fails before handleMCPRequest runs the + // tool. Swallowing here meant tab_close/element_click still executed + // against the browser while the reply was dropped, and the model saw + // only "Tool call timeout" — then reasonably retried the side effect. + throw error; } } @@ -155,7 +197,9 @@ class ConnectionManager { this.mcpSocket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); } else if (this.mcpSocket?.readyState === WebSocket.CLOSED) { console.log('🔄 WebSocket closed, attempting reconnection...'); - this.connect(); + // Background retry: the reconnect loop owns recovery, so a failure + // here is expected and must not become an unhandled rejection. + this.connect().catch(() => {}); } }, 15000); // More frequent heartbeat for better reliability } @@ -176,7 +220,7 @@ class ConnectionManager { this.reconnectInterval = setInterval(() => { if (this.reconnectAttempts < 10) { - this.connect(); + this.connect().catch(() => {}); } else { console.log('❌ Maximum reconnection attempts reached'); this.clearReconnectInterval(); @@ -205,11 +249,12 @@ class ConnectionManager { } send(message) { - if (this.mcpSocket && this.mcpSocket.readyState === WebSocket.OPEN) { - this.mcpSocket.send(JSON.stringify(message)); - } else { - console.error('WebSocket not connected'); + if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) { + // Dropping the frame here left the server's pending call to expire as a + // generic 30s "Tool call timeout", hiding the real cause. + throw new Error(`WebSocket not connected; dropped response for id=${message?.id}`); } + this.mcpSocket.send(JSON.stringify(message)); } getStatus() { @@ -1075,14 +1120,20 @@ async function handleMCPRequest(message) { result, }); } catch (error) { - // Send error response - connectionManager.send({ - id, - error: { - message: error.message, - code: -32603, - }, - }); + // Send error response. Guarded because send() now throws when the socket + // is down, and this is the last handler — an escape here would be an + // unhandled rejection that loses the original error entirely. + try { + connectionManager.send({ + id, + error: { + message: error.message, + code: -32603, + }, + }); + } catch (sendError) { + console.error('Could not deliver error response:', error.message, '|', sendError.message); + } } } @@ -1131,7 +1182,13 @@ async function navigateToUrl(url, waitFor, timeout = 10000) { active: true, currentWindow: true, }); - + + // tabs.query legitimately returns [] (no focused normal window), which used + // to crash here on activeTab.id. Other callers already guard this. + if (!activeTab) { + throw new Error("No active tab found"); + } + await browser.tabs.update(activeTab.id, { url }); // If waitFor is specified, wait for the element to appear @@ -1469,31 +1526,6 @@ async function createTabsBatch(urls, active, wait_for, timeout, batch_settings = return result; } -// Utility function to generate URLs for testing/demo purposes -function generateTestUrls(baseUrl, count) { - const urls = []; - for (let i = 1; i <= count; i++) { - urls.push(`${baseUrl}?tab=${i}`); - } - return urls; -} - -// Batch operation helper functions -function estimateBatchTime(urlCount, batchSettings = {}) { - const { - chunk_size = 5, - delay_between_chunks = 1000, - delay_between_tabs = 200 - } = batchSettings || {}; - - const totalChunks = Math.ceil(urlCount / chunk_size); - const timePerChunk = (chunk_size - 1) * delay_between_tabs; // delays within chunk - const timeForChunks = totalChunks * timePerChunk; - const timeBetweenChunks = (totalChunks - 1) * delay_between_chunks; - - return timeForChunks + timeBetweenChunks; // in milliseconds -} - async function closeTabs(params) { const { tab_id, tab_ids } = params; @@ -1804,17 +1836,10 @@ async function getHistory(params) { }; } catch (error) { - return { - success: false, - error: `History search failed: ${error.message}`, - history_items: [], - metadata: { - total_found: 0, - returned_count: 0, - search_params: params, - execution_time: new Date().toISOString() - } - }; + // Returning an empty history_items here rendered as "No history items + // found" via formatHistoryResult, which never reads success/error — a + // permissions failure was indistinguishable from an empty result. + throw new Error(`History search failed: ${error.message}`); } } @@ -1834,14 +1859,7 @@ async function getSelectedText(params) { try { targetTab = await browser.tabs.get(tab_id); } catch (error) { - return { - success: false, - error: `Tab ${tab_id} not found or inaccessible`, - selected_text: "", - metadata: { - execution_time: new Date().toISOString() - } - }; + throw new Error(`Tab ${tab_id} not found or inaccessible`); } } else { // Get the active tab @@ -1849,16 +1867,9 @@ async function getSelectedText(params) { active: true, currentWindow: true, }); - + if (!activeTab) { - return { - success: false, - error: "No active tab found", - selected_text: "", - metadata: { - execution_time: new Date().toISOString() - } - }; + throw new Error("No active tab found"); } targetTab = activeTab; } @@ -1881,14 +1892,7 @@ async function getSelectedText(params) { const result = results[0]?.result || results[0]; if (!result) { - return { - success: false, - error: "Failed to execute selection script", - selected_text: "", - metadata: { - execution_time: new Date().toISOString() - } - }; + throw new Error("Failed to execute selection script"); } if (!result.hasSelection) { @@ -1940,16 +1944,9 @@ async function getSelectedText(params) { return response; } catch (error) { - return { - success: false, - error: `Failed to get selected text: ${error.message}`, - selected_text: "", - has_selection: false, - metadata: { - execution_time: new Date().toISOString(), - error_details: error.stack - } - }; + // These used to return has_selection: false, which formatSelectedTextResult + // renders as "No text selected" — a failure was reported as an empty page. + throw new Error(`Failed to get selected text: ${error.message}`); } } @@ -2014,7 +2011,9 @@ function getSelectionFunction() { // Initialize connection when extension loads (with delay for server startup) setTimeout(() => { - connectionManager.connect(); + connectionManager.connect().catch((error) => { + console.log('Initial connection attempt failed:', error.message); + }); }, 1000); // Handle messages from popup @@ -2028,8 +2027,12 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => { tools: tools.map(t => t.name) }); } else if (request.action === "reconnect") { - connectionManager.connect(); - sendResponse({ success: true }); + // Report the real outcome: this used to answer success before the socket + // had opened, so the popup's reconnect button always looked like it worked. + connectionManager.connect() + .then(() => sendResponse({ success: true })) + .catch((error) => sendResponse({ success: false, error: error.message })); + return true; } else if (request.action === "getPorts") { sendResponse({ current: lastKnownPorts, @@ -2039,9 +2042,6 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => { safetyModeEnabled = request.enabled; console.log(`🛡️ Safety Mode ${safetyModeEnabled ? 'ENABLED' : 'DISABLED'}`); sendResponse({ success: true }); - } else if (request.action === "test") { - connectionManager.send({ type: "test", timestamp: Date.now() }); - sendResponse({ success: true }); } return true; // Keep the message channel open }); \ No newline at end of file diff --git a/opendia-extension/src/content/content.js b/opendia-extension/src/content/content.js index 0ed48f7..2743c03 100644 --- a/opendia-extension/src/content/content.js +++ b/opendia-extension/src/content/content.js @@ -12,122 +12,6 @@ if (typeof window.OpenDiaContentScriptLoaded !== 'undefined') { console.log("OpenDia enhanced content script loaded"); -// Enhanced Pattern Database with Twitter-First Priority -const ENHANCED_PATTERNS = { - // Authentication patterns - auth: { - login: { - input: [ - "[type='email']", - "[name*='username' i]", - "[placeholder*='email' i]", - "[name*='login' i]", - ], - password: ["[type='password']", "[name*='password' i]"], - submit: [ - "[type='submit']", - "button[form]", - ".login-btn", - "[aria-label*='login' i]", - ], - confidence: 0.9, - }, - signup: { - input: [ - "[name*='register' i]", - "[placeholder*='signup' i]", - "[name*='email' i]", - ], - submit: ["[href*='signup']", ".signup-btn", "[aria-label*='register' i]"], - confidence: 0.85, - }, - }, - - // Content creation patterns - Twitter FIRST - content: { - post_create: { - textarea: [ - "[data-testid='tweetTextarea_0']", // Twitter FIRST (most specific) - "[aria-label='Post text']", // Twitter specific - "[contenteditable='true']", // Generic last - "textarea[placeholder*='post' i]", - "[data-text='true']", - ], - submit: [ - "[data-testid='tweetButtonInline']", // Twitter inline - "[data-testid='tweetButton']", // Twitter main - ".post-btn", - ".publish-btn", - "[aria-label*='post' i]", - ], - confidence: 0.95, - }, - comment: { - textarea: [ - "textarea[placeholder*='comment' i]", - "[role='textbox']", - "[placeholder*='reply' i]", - ], - submit: [ - ".comment-btn", - "[aria-label*='comment' i]", - "[aria-label*='reply' i]", - ], - confidence: 0.8, - }, - }, - - // Search patterns - search: { - global: { - input: [ - "[data-testid='SearchBox_Search_Input']", // Twitter search first - "[type='search']", - "[role='searchbox']", - "[placeholder*='search' i]", - "[name*='search' i]", - ], - submit: [ - "[aria-label*='search' i]", - ".search-btn", - "button[type='submit']", - ], - confidence: 0.85, - }, - }, - - // Navigation patterns - nav: { - menu: { - toggle: [ - "[aria-label*='menu' i]", - ".menu-btn", - ".hamburger", - "[data-toggle='menu']", - ], - items: ["nav a", ".nav-item", "[role='menuitem']"], - confidence: 0.8, - }, - }, - - // Form patterns - form: { - submit: { - button: [ - "[type='submit']", - "button[form]", - ".submit-btn", - "[aria-label*='submit' i]", - ], - confidence: 0.85, - }, - reset: { - button: ["[type='reset']", ".reset-btn", "[aria-label*='reset' i]"], - confidence: 0.8, - }, - }, -}; - // Anti-Detection Platform Configuration const ANTI_DETECTION_PLATFORMS = { "twitter.com": { @@ -161,57 +45,6 @@ const ANTI_DETECTION_PLATFORMS = { }, }; -// Legacy pattern database for backward compatibility -const PATTERN_DATABASE = { - twitter: { - domains: ["twitter.com", "x.com"], - patterns: { - post_tweet: { - textarea: - "[data-testid='tweetTextarea_0'], [contenteditable='true'][data-text='true']", - submit: - "[data-testid='tweetButtonInline'], [data-testid='tweetButton']", - confidence: 0.95, - }, - search: { - input: - "[data-testid='SearchBox_Search_Input'], input[placeholder*='search' i]", - submit: "[data-testid='SearchBox_Search_Button']", - confidence: 0.9, - }, - }, - }, - github: { - domains: ["github.com"], - patterns: { - search: { - input: "input[placeholder*='Search' i].form-control", - submit: "button[type='submit']", - confidence: 0.85, - }, - }, - }, - universal: { - search: { - selectors: [ - "input[type='search']", - "input[placeholder*='search' i]", - "[role='searchbox']", - "input[name*='search' i]", - ], - confidence: 0.6, - }, - submit: { - selectors: [ - "button[type='submit']:not([disabled])", - "input[type='submit']:not([disabled])", - "[role='button'][aria-label*='submit' i]", - ], - confidence: 0.65, - }, - }, -}; - class BrowserAutomation { constructor() { @@ -220,22 +53,6 @@ class BrowserAutomation { this.idCounter = 0; this.quickIdCounter = 0; this.setupMessageListener(); - this.setupViewportAnalyzer(); - } - - setupViewportAnalyzer() { - this.visibilityMap = new Map(); - this.observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - this.visibilityMap.set(entry.target, { - visible: entry.isIntersecting, - ratio: entry.intersectionRatio, - }); - }); - }, - { threshold: [0, 0.1, 0.5, 1.0] } - ); } setupMessageListener() { @@ -610,12 +427,9 @@ class BrowserAutomation { element_ids, max_results = 5, }) { - const startTime = performance.now(); - - // Two-phase approach - if (phase === "discover") { - return await this.quickDiscovery({ intent_hint, max_results }); - } else if (phase === "detailed") { + // The phase enum is discover|detailed on both sides of the wire, so the + // dispatch is total. + if (phase === "detailed") { return await this.detailedAnalysis({ intent_hint, focus_areas, @@ -623,13 +437,7 @@ class BrowserAutomation { max_results, }); } - - // Legacy single-phase approach for backward compatibility - return await this.legacyAnalysis({ - intent_hint, - focus_area: focus_areas?.[0], - max_results, - }); + return await this.quickDiscovery({ intent_hint, max_results }); } async quickDiscovery({ intent_hint, max_results = 5 }) { @@ -667,22 +475,8 @@ class BrowserAutomation { } } - // Fallback to enhanced patterns - if ( - quickMatches.length === 0 && - (bestMethod === "enhanced_pattern_match" || - bestMethod === "pattern_database") - ) { - const patternResult = await this.tryEnhancedPatterns(intent_hint); - if (patternResult.confidence > 0.7) { - quickMatches = patternResult.elements - .slice(0, 3) - .map((el) => this.compressElement(el, true)); - usedMethod = "enhanced_patterns"; - } - } } catch (error) { - console.warn("Enhanced patterns failed:", error); + console.warn("Anti-detection pattern matching failed:", error); } // If no pattern matches, do a quick viewport scan @@ -825,386 +619,6 @@ class BrowserAutomation { return result; } - async legacyAnalysis({ intent_hint, focus_area, max_results = 5 }) { - const startTime = performance.now(); - let result; - - try { - // Try enhanced patterns first - result = await this.tryEnhancedPatterns(intent_hint); - if (result.confidence > 0.8) { - return this.formatAnalysisResult( - result, - "enhanced_patterns", - startTime - ); - } - } catch (error) { - console.warn("Enhanced patterns failed, trying legacy patterns:", error); - try { - // Fallback to legacy pattern database - result = await this.tryPatternDatabase(intent_hint); - if (result.confidence > 0.8) { - return this.formatAnalysisResult( - result, - "pattern_database", - startTime - ); - } - } catch (legacyError) { - console.warn("Legacy pattern database failed:", legacyError); - } - } - - // Final fallback to semantic analysis - result = await this.trySemanticAnalysis(intent_hint, focus_area); - return this.formatAnalysisResult(result, "semantic_analysis", startTime); - } - - async tryEnhancedPatterns(intent_hint) { - const [category, action] = this.parseIntent(intent_hint); - const pattern = ENHANCED_PATTERNS[category]?.[action]; - - if (!pattern) { - return this.tryUniversalPatterns(intent_hint); - } - - const elements = this.findPatternElements(pattern); - return { - elements: elements.slice(0, 3), - confidence: pattern.confidence, - method: "enhanced_pattern_match", - category, - action, - }; - } - - parseIntent(intent) { - const intentLower = intent.toLowerCase(); - - // Check for authentication patterns - if ( - intentLower.includes("login") || - intentLower.includes("sign in") || - intentLower.includes("log in") - ) { - return ["auth", "login"]; - } - if ( - intentLower.includes("signup") || - intentLower.includes("sign up") || - intentLower.includes("register") || - intentLower.includes("create account") - ) { - return ["auth", "signup"]; - } - - // Check for content creation patterns - if ( - intentLower.includes("tweet") || - intentLower.includes("post") || - intentLower.includes("compose") || - intentLower.includes("create") || - intentLower.includes("write") || - intentLower.includes("publish") - ) { - return ["content", "post_create"]; - } - if (intentLower.includes("comment") || intentLower.includes("reply")) { - return ["content", "comment"]; - } - - // Check for search patterns - if ( - intentLower.includes("search") || - intentLower.includes("find") || - intentLower.includes("look for") - ) { - return ["search", "global"]; - } - - // Check for navigation patterns - if ( - intentLower.includes("menu") || - intentLower.includes("navigation") || - intentLower.includes("nav") - ) { - return ["nav", "menu"]; - } - - // Check for form patterns - if ( - intentLower.includes("submit") || - intentLower.includes("send") || - intentLower.includes("save") - ) { - return ["form", "submit"]; - } - if ( - intentLower.includes("reset") || - intentLower.includes("clear") || - intentLower.includes("cancel") - ) { - return ["form", "reset"]; - } - - // Fallback - try to infer from context - if (intentLower.includes("button") || intentLower.includes("click")) { - return ["form", "submit"]; - } - if ( - intentLower.includes("input") || - intentLower.includes("field") || - intentLower.includes("text") - ) { - return ["content", "post_create"]; - } - - // Default fallback - return ["content", "post_create"]; // More useful default than search - } - - findPatternElements(pattern) { - const elements = []; - - for (const [elementType, selectors] of Object.entries(pattern)) { - if (elementType === "confidence") continue; - - for (const selector of selectors) { - const element = document.querySelector(selector); - if (element && this.isLikelyVisible(element)) { - const elementId = this.registerElement(element); - elements.push({ - id: elementId, - type: elementType, - selector: selector, - name: this.getElementName(element), - confidence: pattern.confidence || 0.8, - element: element, - }); - break; // Take first match per element type - } - } - } - - return elements; - } - - tryUniversalPatterns(intent_hint) { - const intentLower = intent_hint.toLowerCase(); - let selectors = []; - - // Content creation patterns - if ( - intentLower.includes("tweet") || - intentLower.includes("post") || - intentLower.includes("compose") || - intentLower.includes("create") || - intentLower.includes("write") - ) { - selectors = [ - "[data-testid='tweetTextarea_0']", // Twitter first! - "[contenteditable='true']", - "textarea[placeholder*='tweet' i]", - "textarea[placeholder*='post' i]", - "textarea[placeholder*='what' i]", - "[data-text='true']", - "[role='textbox']", - "textarea:not([style*='display: none'])", - ]; - } - // Authentication patterns - else if (intentLower.includes("login") || intentLower.includes("sign in")) { - selectors = [ - "[type='email']", - "[name*='username' i]", - "[placeholder*='email' i]", - "[placeholder*='username' i]", - "input[name*='login' i]", - ]; - } else if ( - intentLower.includes("signup") || - intentLower.includes("register") - ) { - selectors = [ - "[href*='signup']", - ".signup-btn", - "[aria-label*='register' i]", - "button[data-testid*='signup' i]", - "a[href*='register']", - ]; - } - // Search patterns - else if (intentLower.includes("search") || intentLower.includes("find")) { - selectors = [ - "[data-testid='SearchBox_Search_Input']", // Twitter search first - "[type='search']", - "[role='searchbox']", - "[placeholder*='search' i]", - "[data-testid*='search' i]", - "input[name*='search' i]", - ]; - } - // Generic fallback - look for interactive elements - else { - selectors = [ - "button:not([disabled])", - "[contenteditable='true']", - "textarea", - "[type='submit']", - "[role='button']", - "input[type='text']", - ]; - } - - const elements = []; - - for (const selector of selectors) { - const foundElements = document.querySelectorAll(selector); - for (const element of foundElements) { - if (this.isLikelyVisible(element)) { - const elementId = this.registerElement(element); - elements.push({ - id: elementId, - type: this.inferElementType(element, intent_hint), - selector: selector, - name: this.getElementName(element), - confidence: - 0.5 + this.calculateConfidence(element, intent_hint) * 0.3, - element: element, - }); - if (elements.length >= 3) break; // Limit to 3 elements - } - } - if (elements.length >= 3) break; - } - - return { - elements, - confidence: - elements.length > 0 - ? Math.max(...elements.map((e) => e.confidence)) - : 0, - method: "universal_pattern", - }; - } - - async tryPatternDatabase(intentHint) { - const hostname = window.location.hostname; - const siteKey = this.detectSite(hostname); - - if (siteKey === "universal") { - return this.getUniversalPattern(intentHint); - } - - const siteConfig = PATTERN_DATABASE[siteKey]; - const pattern = siteConfig?.patterns?.[intentHint]; - - if (!pattern) { - throw new Error(`No pattern found for ${intentHint} on ${siteKey}`); - } - - const elements = []; - for (const [elementType, selector] of Object.entries(pattern)) { - if (elementType === "confidence") continue; - - const element = document.querySelector(selector); - if (element) { - const elementId = this.registerElement(element); - elements.push({ - id: elementId, - type: elementType, - selector: selector, - name: this.getElementName(element), - confidence: pattern.confidence || 0.8, - }); - } - } - - return { - elements, - confidence: pattern.confidence || 0.8, - site: siteKey, - }; - } - - detectSite(hostname) { - for (const [siteKey, config] of Object.entries(PATTERN_DATABASE)) { - if (siteKey === "universal") continue; - if ( - config.domains?.some( - (domain) => hostname === domain || hostname.endsWith(`.${domain}`) - ) - ) { - return siteKey; - } - } - return "universal"; - } - - getUniversalPattern(intentHint) { - const universalPatterns = PATTERN_DATABASE.universal; - const pattern = universalPatterns[intentHint]; - - if (!pattern) { - throw new Error(`No universal pattern for ${intentHint}`); - } - - const elements = []; - for (const selector of pattern.selectors) { - const element = document.querySelector(selector); - if (element) { - const elementId = this.registerElement(element); - elements.push({ - id: elementId, - type: intentHint, - selector: selector, - name: this.getElementName(element), - confidence: pattern.confidence, - }); - break; // Take first match for universal patterns - } - } - - return { - elements, - confidence: pattern.confidence, - site: "universal", - }; - } - - async trySemanticAnalysis(intentHint, focusArea) { - const relevantElements = document.querySelectorAll(` - button, input, select, textarea, a[href], - [role="button"], [role="textbox"], [role="searchbox"], - [aria-label], [data-testid] - `); - - const elements = Array.from(relevantElements) - .filter((el) => this.isVisible(el)) - .slice(0, 20) - .map((element) => { - const elementId = this.registerElement(element); - return { - id: elementId, - type: this.inferElementType(element, intentHint), - selector: this.generateSelector(element), - name: this.getElementName(element), - confidence: this.calculateConfidence(element, intentHint), - }; - }) - .filter((el) => el.confidence > 0.3) - .sort((a, b) => b.confidence - a.confidence); - - return { - elements, - confidence: - elements.length > 0 - ? Math.max(...elements.map((e) => e.confidence)) - : 0, - }; - } - async extractContent({ content_type, max_items = 20, summarize = true }) { console.log(`🔍 extractContent called with: content_type=${content_type}, max_items=${max_items}, summarize=${summarize}`); const startTime = performance.now(); @@ -1399,8 +813,9 @@ class BrowserAutomation { total_results: results.length, result_types: [...new Set(results.map((r) => r.type))], top_domains: this.getTopDomains(domains), - avg_score: - results.reduce((sum, r) => sum + (r.score || 0), 0) / results.length, + avg_score: results.length + ? results.reduce((sum, r) => sum + (r.score || 0), 0) / results.length + : 0, has_sponsored: results.some((r) => r.type === "sponsored"), quality_score: this.calculateQualityScore(results), }; @@ -1418,10 +833,10 @@ class BrowserAutomation { return { post_count: posts.length, - avg_length: Math.round(totalTextLength / posts.length), + avg_length: posts.length ? Math.round(totalTextLength / posts.length) : 0, has_media_count: posts.filter((p) => p.has_media).length, engagement_total: totalLikes, - avg_engagement: Math.round(totalLikes / posts.length), + avg_engagement: posts.length ? Math.round(totalLikes / posts.length) : 0, post_types: [...new Set(posts.map((p) => p.post_type))], authors: [...new Set(posts.map((p) => p.author).filter(Boolean))].length, estimated_tokens: Math.ceil(totalTextLength / 4), @@ -1585,6 +1000,10 @@ class BrowserAutomation { } calculateQualityScore(results) { + // An extractor that matched nothing returns [], which used to make every + // term NaN and ship "NaN%" to the model. + if (results.length === 0) return 0; + const avgScore = results.reduce((sum, r) => sum + (r.score || 0), 0) / results.length; const hasLinks = results.filter((r) => r.link).length / results.length; @@ -1911,13 +1330,6 @@ class BrowserAutomation { ); } - isVisible(element) { - return ( - element.offsetParent !== null && - getComputedStyle(element).visibility !== "hidden" && - getComputedStyle(element).opacity !== "0" - ); - } generateSelector(element) { if (element.id) return `#${element.id}`; @@ -1958,14 +1370,6 @@ class BrowserAutomation { return Math.min(confidence, 1.0); } - formatAnalysisResult(result, method, startTime) { - return { - ...result, - method, - execution_time: Math.round(performance.now() - startTime), - analyzed_at: new Date().toISOString(), - }; - } // Two-phase utility methods compressElement(element, isQuick = false) { @@ -2281,11 +1685,6 @@ class BrowserAutomation { ); } - estimateTokenUsage(result) { - // Estimate token count based on result size - const jsonString = JSON.stringify(result); - return Math.ceil(jsonString.length / 4); // Rough estimate: 4 chars per token - } // Get all links on the page with filtering options async getPageLinks(options = {}) { const { @@ -2524,7 +1923,11 @@ class BrowserAutomation { // 🎨 Page Styling System async handlePageStyle(data) { - const { mode, theme, background, text_color, font, font_size, mood, intensity, effect, duration, remember } = data; + // intensity and duration default in the tool schema but were destructured + // without defaults, so effects never auto-removed (applyEffect checks + // `duration > 0`) and the description rendered "for undefineds". + const { mode, theme, background, text_color, font, font_size, mood, + intensity = 'medium', effect, duration = 10, remember } = data; // Remove existing custom styles const existingStyle = document.getElementById('opendia-custom-style'); diff --git a/opendia-extension/src/popup/popup.html b/opendia-extension/src/popup/popup.html index 6504a21..89989ad 100644 --- a/opendia-extension/src/popup/popup.html +++ b/opendia-extension/src/popup/popup.html @@ -316,7 +316,6 @@