optimize: codebase cleanup across 8 dimensions + tools/list_changed (#63)

One commit per cleanup domain, plus notifications/tools/list_changed.

Fixes the CI structure tests, which computed every check then discarded it and
could not fail. Aligns get_page_links with the params its implementation reads,
five formatter fields that rendered undefined, and stale-socket handling in the
extension's reconnect loop.
This commit is contained in:
@aaronjmars 2026-07-29 14:16:33 -04:00 • committed by GitHub
commit f9d1b06ec0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 353 additions and 428 deletions

3
.gitignore vendored
View file

@ -95,6 +95,9 @@ out
.nuxt
dist
# Packaged extension builds (published via GitHub Releases)
opendia-extension/releases/
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js

View file

@ -74,9 +74,17 @@ cd dist/opendia-dxt
npm install --production --silent
cd ../..
# Copy browser extension
# Build and copy browser extension. The install instructions below point at
# extension/dist/{chrome,firefox}, so those have to exist in the bundle.
echo "🌐 Building browser extension..."
cd opendia-extension
npm install --silent
npm run build --silent
cd ..
echo "🌐 Copying browser extension..."
cp -r opendia-extension dist/opendia-dxt/extension
rm -rf dist/opendia-dxt/extension/node_modules
# Copy logo/icon files for DXT - try multiple sources
echo "🎨 Copying logo files..."
@ -290,7 +298,7 @@ Download the latest extension from: https://github.com/aeonfun/opendia/releases
2. **Install Extension**
- Click "Load unpacked"
- Select the `extension/` folder from this DXT package
- Select the `extension/dist/chrome/` folder from this DXT package
- Extension should appear in your extensions list with OpenDia icon
#### For Firefox
@ -298,7 +306,7 @@ Download the latest extension from: https://github.com/aeonfun/opendia/releases
1. **Load Temporary Add-on**
- Go to `about:debugging#/runtime/this-firefox`
- Click "Load Temporary Add-on..."
- Select the `manifest-firefox.json` file from the `extension/` folder
- Select the `manifest.json` file from the `extension/dist/firefox/` folder
> **Firefox Note**: Extensions are loaded as temporary add-ons and will be removed when Firefox restarts. For permanent installation, use the signed extension from GitHub releases.

View file

@ -31,15 +31,6 @@ async function buildForBrowser(browser) {
path.join(buildDir, 'src/polyfill/browser-polyfill.min.js')
);
// Browser-specific post-processing
if (browser === 'chrome') {
console.log('📦 Chrome MV3: Service worker mode enabled');
// No additional processing needed for Chrome
} else if (browser === 'firefox') {
console.log('🦊 Firefox MV2: Background page mode enabled');
// No additional processing needed for Firefox
}
console.log(`✅ ${browser} extension built successfully in ${buildDir}`);
}

View file

@ -14,8 +14,6 @@
"activeTab",
"storage",
"scripting",
"webNavigation",
"notifications",
"bookmarks",
"history"
],

View file

@ -19,12 +19,8 @@
"tabs",
"activeTab",
"storage",
"webNavigation",
"notifications",
"bookmarks",
"history",
"webRequest",
"webRequestBlocking",
"<all_urls>"
],
"background": {

View file

@ -1,43 +0,0 @@
{
"manifest_version": 3,
"name": "OpenDia",
"version": "1.1.0",
"description": "Connect your browser to AI models",
"icons": {
"16": "icon-16.png",
"32": "icon-32.png",
"48": "icon-48.png",
"128": "icon-128.png"
},
"permissions": [
"tabs",
"activeTab",
"storage",
"scripting",
"webNavigation",
"notifications",
"bookmarks",
"history"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "OpenDia"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"externally_connectable": {
"ids": ["*"],
"matches": ["http://localhost/*"]
}
}

View file

@ -33,7 +33,7 @@ browser.storage.local.get(['safetyMode'], (result) => {
class ConnectionManager {
constructor() {
this.mcpSocket = null;
this.reconnectInterval = null;
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.heartbeatInterval = null;
this.isServiceWorker = browserInfo.isServiceWorker;
@ -69,22 +69,11 @@ class ConnectionManager {
});
}
// Reuse a live socket: reconnecting per operation replaced the socket the
// request arrived on, so the reply was written to a CONNECTING socket.
async connect() {
if (this.isServiceWorker) {
// 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) {
console.log('🦊 Firefox MV2: Creating persistent connection');
await this.createConnection();
} else {
console.log('🦊 Firefox MV2: Using existing connection');
}
if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) {
await this.createConnection();
}
}
@ -93,49 +82,55 @@ 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();
// 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.
// Deliberately no reset: finding a port is not evidence the connection
// opened. The reset lives in onopen so backoff can actually grow.
}
console.log('🔗 Connecting to MCP server at', MCP_SERVER_URL);
this.mcpSocket = new WebSocket(MCP_SERVER_URL);
this.mcpSocket.onopen = () => {
// Handlers are bound to this specific socket. The server closes the old
// socket when the extension reconnects, so a stale close event can arrive
// after a healthy one is live; without the identity check it would clear
// the new connection's heartbeat and arm a reconnect that never clears.
// Mirrors the `chromeExtensionSocket === ws` guard on the server side.
const socket = new WebSocket(MCP_SERVER_URL);
this.mcpSocket = socket;
socket.onopen = () => {
if (this.mcpSocket !== socket) return;
console.log('✅ Connected to MCP server');
this.clearReconnectInterval();
this.clearReconnectTimer();
this.reconnectAttempts = 0; // Reset attempts on successful connection
const tools = getAvailableTools();
console.log(`🔧 Registering ${tools.length} tools:`, tools.map(t => t.name));
// Register available browser functions
this.mcpSocket.send(JSON.stringify({
socket.send(JSON.stringify({
type: 'register',
tools: tools
}));
// Setup heartbeat for persistent connections
if (!this.isServiceWorker) {
this.setupHeartbeat();
}
};
this.mcpSocket.onmessage = async (event) => {
socket.onmessage = async (event) => {
if (this.mcpSocket !== socket) return;
const message = JSON.parse(event.data);
await handleMCPRequest(message);
};
this.mcpSocket.onclose = (event) => {
socket.onclose = (event) => {
if (this.mcpSocket !== socket) return;
console.log(`❌ Disconnected from MCP server (code: ${event.code}, reason: ${event.reason})`);
this.clearHeartbeat(); // Clear heartbeat on disconnect
this.reconnectAttempts++;
// Check if this was a normal closure or abnormal
if (event.code !== 1000 && event.code !== 1001) {
console.log('🔄 Abnormal WebSocket closure, will attempt reconnection');
if (!this.isServiceWorker) {
// Firefox: Attempt to reconnect
this.scheduleReconnect();
@ -145,13 +140,15 @@ class ConnectionManager {
console.log('🔄 Normal WebSocket closure');
}
};
this.mcpSocket.onerror = (error) => {
// No attempt counting here: onclose always follows onerror for a failed
// socket, so incrementing in both halved the effective retry budget.
socket.onerror = (error) => {
if (this.mcpSocket !== socket) return;
console.log('⚠️ MCP WebSocket error:', error);
this.reconnectAttempts++;
};
await this.waitForSocketOpen(this.mcpSocket);
await this.waitForSocketOpen(socket);
} catch (error) {
console.error('Connection failed:', error);
@ -211,40 +208,36 @@ class ConnectionManager {
}
}
// One-shot, not an interval: a fixed period fires again while the previous
// attempt is still in port discovery (7 sequential un-timed fetches), which
// overwrites this.mcpSocket and orphans the pending socket. Failure re-arms
// via createConnection's catch and onclose, so nothing is lost.
scheduleReconnect() {
this.clearReconnectInterval();
this.clearReconnectTimer();
if (this.reconnectAttempts >= 10) {
console.log('❌ Maximum reconnection attempts reached');
return;
}
// Exponential backoff for reconnection attempts
const backoffTime = Math.min(5000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`🔄 Scheduling reconnection in ${backoffTime}ms (attempt ${this.reconnectAttempts})`);
this.reconnectInterval = setInterval(() => {
if (this.reconnectAttempts < 10) {
this.connect().catch(() => {});
} else {
console.log('❌ Maximum reconnection attempts reached');
this.clearReconnectInterval();
}
this.reconnectTimer = setTimeout(() => {
this.connect().catch(() => {});
}, backoffTime);
}
clearReconnectInterval() {
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
async ensureConnection() {
if (this.isServiceWorker) {
// Chrome: Always create fresh connection
await this.connect();
} else {
// Firefox: Use existing or create new
if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) {
await this.connect();
}
}
await this.connect();
return this.mcpSocket;
}
@ -270,25 +263,30 @@ class ConnectionManager {
const connectionManager = new ConnectionManager();
// Content script management for background tabs
// Ping the content script in a tab. Rejects on timeout or extension error.
function pingContentScript(tabId, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Content script ping timeout after ${timeoutMs}ms`));
}, timeoutMs);
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
clearTimeout(timer);
if (browser.runtime.lastError) {
reject(new Error(browser.runtime.lastError.message));
} else {
resolve(response);
}
});
});
}
async function ensureContentScriptReady(tabId, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
// Test if content script is responsive
const response = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Content script ping timeout'));
}, 2000);
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
clearTimeout(timeout);
if (browser.runtime.lastError) {
reject(new Error(browser.runtime.lastError.message));
} else {
resolve(response);
}
});
});
const response = await pingContentScript(tabId, 2000);
if (response && response.success) {
console.log(`✅ Content script ready in tab ${tabId}`);
return true;
@ -339,18 +337,8 @@ async function ensureContentScriptReady(tabId, retries = 3) {
await new Promise(resolve => setTimeout(resolve, 1000));
// Test again
const testResponse = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout after injection')), 3000);
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
clearTimeout(timeout);
if (browser.runtime.lastError) {
reject(new Error(browser.runtime.lastError.message));
} else {
resolve(response);
}
});
});
const testResponse = await pingContentScript(tabId, 3000);
if (testResponse && testResponse.success) {
console.log(`✅ Content script successfully injected into tab ${tabId}`);
return true;
@ -402,14 +390,9 @@ async function getTabContentScriptStatus(tabId) {
return { ready: false, reason: 'restricted_url', url: tab.url };
}
const response = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => resolve(null), 3000); // Increase to 3 seconds
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
clearTimeout(timeout);
resolve(response);
});
});
// A failed ping is the answer here, not an error: report the tab as not loaded.
const response = await pingContentScript(tabId, 3000).catch(() => null);
if (response && response.success) {
return { ready: true, reason: 'active', url: tab.url };
} else {
@ -945,20 +928,23 @@ function getAvailableTools() {
inputSchema: {
type: "object",
properties: {
link_type: {
type: "string",
enum: ["all", "internal", "external"],
default: "all",
description: "Filter by internal/external links"
include_internal: {
type: "boolean",
default: true,
description: "Include links pointing at the current domain"
},
domains: {
type: "array",
items: { type: "string" },
description: "Filter by specific domains (optional)"
include_external: {
type: "boolean",
default: true,
description: "Include links pointing off the current domain"
},
domain_filter: {
type: "string",
description: "Only return links whose domain contains this string (optional)"
},
max_results: {
type: "number",
default: 50,
default: 100,
maximum: 200,
description: "Maximum links to return"
}
@ -1137,30 +1123,34 @@ async function handleMCPRequest(message) {
}
}
// Enhanced content script communication with background tab support
async function sendToContentScript(action, data, targetTabId = null) {
let targetTab;
if (targetTabId) {
// Use specific tab
// Content script communication, targeting a specific tab or the active one
// Resolve the tab a tool should act on: an explicit tab id, else the active tab.
async function resolveTargetTab(tabId = null) {
if (tabId) {
try {
targetTab = await browser.tabs.get(targetTabId);
return await browser.tabs.get(tabId);
} catch (error) {
throw new Error(`Tab ${targetTabId} not found or inaccessible`);
// tabs.get also rejects for reasons other than "not found" (bad id type,
// incognito access denied), so keep the underlying message.
throw new Error(`Tab ${tabId} not found or inaccessible: ${error.message}`);
}
} else {
// Fallback to active tab (maintains compatibility)
const [activeTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
if (!activeTab) {
throw new Error('No active tab found');
}
targetTab = activeTab;
}
// tabs.query legitimately returns [] when there is no focused normal window.
const [activeTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
if (!activeTab) {
throw new Error('No active tab found');
}
return activeTab;
}
async function sendToContentScript(action, data, targetTabId = null) {
const targetTab = await resolveTargetTab(targetTabId);
// Ensure content script is available in the target tab
await ensureContentScriptReady(targetTab.id);
@ -1178,16 +1168,7 @@ async function sendToContentScript(action, data, targetTabId = null) {
}
async function navigateToUrl(url, waitFor, timeout = 10000) {
const [activeTab] = await browser.tabs.query({
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");
}
const activeTab = await resolveTargetTab();
await browser.tabs.update(activeTab.id, { url });
@ -1231,7 +1212,7 @@ async function waitForElement(tabId, selector, timeout = 5000) {
throw new Error(`Timeout waiting for element: ${selector}`);
}
// Enhanced Tab Management Functions with Batch Support
// Tab management, single and batched
async function createTab(params) {
const {
url,
@ -1267,7 +1248,7 @@ async function createTab(params) {
const urlArray = Array(count).fill(url);
return await createTabsBatch(urlArray, active, wait_for, timeout, batch_settings);
} else {
// Single tab creation (legacy behavior)
// Single tab creation
console.log(`📱 Using single tab mode for: ${url || 'about:blank'}`);
return await createSingleTab(url, active, wait_for, timeout);
}
@ -1309,15 +1290,17 @@ function validateTabCreateParams(params) {
}
}
// Validate count
if (count < 1 || count > 50) {
return { valid: false, error: "Count must be between 1 and 50" };
// Validate count. Both comparisons coerce, so a string or fractional count
// passed the range check and then broke Array(count): "5" yielded a single tab
// reported as a full success, 2.5 threw an opaque "Invalid array length".
if (!Number.isInteger(count) || count < 1 || count > 50) {
return { valid: false, error: "Count must be an integer between 1 and 50" };
}
return { valid: true };
}
// Single tab creation (original behavior)
// Single tab creation
async function createSingleTab(url, active, wait_for, timeout) {
const createProperties = { active };
if (url) {
@ -1393,11 +1376,18 @@ async function createTabsBatch(urls, active, wait_for, timeout, batch_settings =
console.log('🔍 createTabsBatch called with:', { urls: urls.length, batch_settings });
const {
chunk_size = 5,
chunk_size: requested_chunk_size = 5,
delay_between_chunks = 1000,
delay_between_tabs = 200
} = batch_settings || {};
// A destructuring default only covers undefined, so an explicit 0 or a negative
// survived and the chunk loop never advanced - an unbounded spin in the worker
// while the server call timed out at 30s.
const chunk_size = Number.isInteger(requested_chunk_size) && requested_chunk_size > 0
? requested_chunk_size
: 5;
const startTime = Date.now();
const totalTabs = urls.length;
const createdTabs = [];
@ -1497,6 +1487,7 @@ async function createTabsBatch(urls, active, wait_for, timeout, batch_settings =
total_requested: totalTabs,
successful: successCount,
failed: errorCount,
chunks_processed: Math.ceil(totalTabs / chunk_size),
execution_time_ms: executionTime
},
// Only include full tab details for small batches
@ -1852,27 +1843,7 @@ async function getSelectedText(params) {
} = params;
try {
let targetTab;
if (tab_id) {
// Use specific tab
try {
targetTab = await browser.tabs.get(tab_id);
} catch (error) {
throw new Error(`Tab ${tab_id} not found or inaccessible`);
}
} else {
// Get the active tab
const [activeTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
if (!activeTab) {
throw new Error("No active tab found");
}
targetTab = activeTab;
}
const targetTab = await resolveTargetTab(tab_id);
// Execute script to get selected text - handle browser differences
let results;
@ -1944,8 +1915,8 @@ async function getSelectedText(params) {
return response;
} catch (error) {
// These used to return has_selection: false, which formatSelectedTextResult
// renders as "No text selected" — a failure was reported as an empty page.
// Throw rather than returning has_selection:false, which the formatter
// renders as "No text selected" - a failure indistinguishable from success.
throw new Error(`Failed to get selected text: ${error.message}`);
}
}
@ -2027,8 +1998,8 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
tools: tools.map(t => t.name)
});
} else if (request.action === "reconnect") {
// 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.
// Answer after the socket opens, not before, so the popup reports the
// real outcome rather than always looking successful.
connectionManager.connect()
.then(() => sendResponse({ success: true }))
.catch((error) => sendResponse({ success: false, error: error.message }));

View file

@ -1,4 +1,5 @@
// Enhanced Browser Automation Content Script with Anti-Detection
// Browser automation content script. Fills React-controlled editors via
// execCommand, which they accept where programmatic value writes are ignored.
// Import WebExtension polyfill for cross-browser compatibility
if (typeof browser === 'undefined' && typeof chrome !== 'undefined') {
globalThis.browser = chrome;
@ -219,7 +220,7 @@ class BrowserAutomation {
async executeDirectBypass(element, value, platformConfig, element_id) {
try {
console.log(`🐦 Executing ${platformConfig.bypassMethod} bypass`);
console.log(`🔓 Executing ${platformConfig.bypassMethod} bypass`);
switch (platformConfig.bypassMethod) {
case "twitter_direct":
@ -247,23 +248,20 @@ class BrowserAutomation {
}
async twitterDirectBypass(element, value, element_id) {
// THE WORKING FORMULA FOR TWITTER:
// 1. Focus 2. Click 3. execCommand
console.log("🐦 Twitter direct bypass - focus+click+execCommand");
// Ensure element is in view
element.scrollIntoView({ behavior: "smooth", block: "center" });
await new Promise((r) => setTimeout(r, 200));
// The magic sequence that bypasses Twitter detection
// Draft.js ignores programmatic .value writes and synthesized input events.
// execCommand("insertText") produces a trusted beforeinput, which the editor's
// own handler accepts and mirrors into React state. Focus and click first:
// the handler is only attached once the editor considers itself active.
element.focus();
element.click();
const execResult = document.execCommand("insertText", false, value);
// Wait for React state to update
// The editor commits on its next render; reading sooner sees the old value.
await new Promise((r) => setTimeout(r, 500));
// Verify success
const currentText = element.textContent || element.value || "";
const success = currentText.includes(value);
@ -279,16 +277,13 @@ class BrowserAutomation {
}
async linkedinDirectBypass(element, value, element_id) {
console.log("💼 LinkedIn direct bypass");
element.scrollIntoView({ behavior: "smooth", block: "center" });
await new Promise((r) => setTimeout(r, 200));
// LinkedIn-specific sequence
element.focus();
element.click();
// Clear existing content first for LinkedIn
// LinkedIn's editor appends rather than replaces, so clear before inserting.
if (element.textContent) {
document.execCommand("selectAll");
document.execCommand("delete");
@ -296,7 +291,7 @@ class BrowserAutomation {
const execResult = document.execCommand("insertText", false, value);
// LinkedIn needs more time for state updates
// Slower to commit than Twitter's; 500ms reads back a partial value.
await new Promise((r) => setTimeout(r, 800));
const currentText = element.textContent || element.value || "";
@ -314,16 +309,12 @@ class BrowserAutomation {
}
async facebookDirectBypass(element, value, element_id) {
console.log("📘 Facebook direct bypass");
element.scrollIntoView({ behavior: "smooth", block: "center" });
await new Promise((r) => setTimeout(r, 200));
// Facebook-specific sequence
element.focus();
element.click();
// Facebook may need selection clearing
if (element.textContent) {
document.execCommand("selectAll");
document.execCommand("delete");
@ -331,7 +322,8 @@ class BrowserAutomation {
const execResult = document.execCommand("insertText", false, value);
// Trigger Facebook-specific events
// Facebook's composer also listens for these directly, unlike Twitter's,
// which picks the change up from beforeinput alone.
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
@ -351,13 +343,12 @@ class BrowserAutomation {
};
}
// Reached only if a platform config names a bypassMethod without its own case.
// Kept as the extension point for adding a platform to ANTI_DETECTION_PLATFORMS.
async genericDirectBypass(element, value, element_id) {
console.log("🔧 Generic direct bypass");
element.scrollIntoView({ behavior: "smooth", block: "center" });
await new Promise((r) => setTimeout(r, 200));
// Generic direct sequence
element.focus();
element.click();
const execResult = document.execCommand("insertText", false, value);
@ -390,7 +381,7 @@ class BrowserAutomation {
throw new Error(`Element not found: ${element_id}`);
}
// Enhanced focus sequence for modern web apps
// Modern editors attach their input handler on focus, not on load.
if (force_focus) {
await this.ensureProperFocus(element);
} else {
@ -567,7 +558,6 @@ class BrowserAutomation {
max_results = 10,
}) {
const startTime = performance.now();
const pageType = this.detectPageType();
// Use default max results limit
max_results = Math.min(max_results, 7); // Allow slightly more for detailed analysis
@ -620,7 +610,6 @@ class BrowserAutomation {
}
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();
const extractors = {
article: () => this.extractArticleContent(summarize),
@ -650,8 +639,7 @@ class BrowserAutomation {
extracted_at: new Date().toISOString(),
};
} else {
// Legacy full content extraction
console.log(`🎯 Returning FULL content: ${rawContent?.content?.length || 0} characters`);
// Full content, returned when summarize is false
return {
content: rawContent,
method: "semantic_extraction",
@ -663,7 +651,6 @@ class BrowserAutomation {
}
extractArticleContent(summarize = true) {
console.log(`📄 extractArticleContent called with summarize=${summarize}`);
const article = document.querySelector(
'article, [role="article"], .article-content, main'
);
@ -672,8 +659,6 @@ class BrowserAutomation {
?.textContent?.trim();
const content = article?.textContent?.trim() || this.extractMainContent();
console.log(`📏 Extracted content length: ${content?.length || 0} characters`);
console.log(`📝 Content preview: ${content?.substring(0, 200)}...`);
return {
title,
@ -1000,7 +985,7 @@ class BrowserAutomation {
}
calculateQualityScore(results) {
// An extractor that matched nothing returns [], which used to make every
// An extractor that matched nothing returns [], which would make every
// term NaN and ship "NaN%" to the model.
if (results.length === 0) return 0;
@ -1295,6 +1280,7 @@ class BrowserAutomation {
if (checkCondition()) {
return {
condition_met: true,
condition_type: condition_type,
wait_time: Date.now() - startTime,
};
}
@ -1626,7 +1612,6 @@ class BrowserAutomation {
}
async fullEnhancedAnalysis(intent_hint, max_results) {
// Enhanced version of semantic analysis with better filtering
const relevantElements = document.querySelectorAll(`
button, input, select, textarea, a[href],
[role="button"], [role="textbox"], [role="searchbox"],
@ -2050,12 +2035,10 @@ class BrowserAutomation {
};
const style = moodMap[mood.toLowerCase()] || moodMap['cozy coffee shop'];
return this.buildMoodCSS(style, intensity);
return this.buildMoodCSS(style);
}
buildMoodCSS(style, intensity) {
const opacity = intensity === 'subtle' ? '0.3' : intensity === 'medium' ? '0.6' : '0.9';
buildMoodCSS(style) {
let css = `
body {
background: ${style.background} !important;

View file

@ -15,8 +15,7 @@ let currentPage = document.getElementById("currentPage");
let serverUrl = document.getElementById("serverUrl");
// Shown when the background script can't be reached. Kept as one list so the
// name list and the count can't disagree — they previously said 17 and omitted
// page_style, while the extension registered 18.
// names and the count cannot disagree.
const KNOWN_TOOLS = [
"page_analyze", "page_extract_content", "element_click", "element_fill",
"element_get_state", "page_navigate", "page_wait_for", "page_scroll",

View file

@ -1,182 +1,151 @@
// Simple test script to verify browser extension compatibility
// Structural checks over the built extensions in dist/.
// Every check is an assertion: a failing probe fails the process, so CI gates on it.
const fs = require('fs');
const path = require('path');
// Prints " <label>: Yes/No" and returns the boolean, so callers can fold results.
function check(label, ok) {
console.log(` ${label}: ${ok ? 'Yes' : 'No'}`);
return ok;
}
function testManifestStructure(browser) {
console.log(`\n🔍 Testing ${browser} extension structure...`);
const buildDir = `dist/${browser}`;
const manifestPath = path.join(buildDir, 'manifest.json');
try {
// Read and parse manifest
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Check manifest version
const results = [];
console.log(` Manifest version: ${manifest.manifest_version}`);
// Check background configuration
if (browser === 'chrome') {
console.log(` Background: Service Worker (${manifest.background.service_worker})`);
console.log(` Action: ${manifest.action ? 'Present' : 'Missing'}`);
console.log(` Host permissions: ${manifest.host_permissions ? manifest.host_permissions.length : 0}`);
results.push(check('Manifest V3', manifest.manifest_version === 3));
results.push(check('Background service worker', Boolean(manifest.background?.service_worker)));
results.push(check('Action', Boolean(manifest.action)));
results.push(check('Host permissions', (manifest.host_permissions || []).length > 0));
} else {
console.log(` Background: Scripts (${manifest.background.scripts.length} files)`);
console.log(` Browser action: ${manifest.browser_action ? 'Present' : 'Missing'}`);
console.log(` Gecko ID: ${manifest.applications?.gecko?.id || 'Not set'}`);
results.push(check('Manifest V2', manifest.manifest_version === 2));
results.push(check('Background scripts', (manifest.background?.scripts || []).length > 0));
results.push(check('Browser action', Boolean(manifest.browser_action)));
results.push(check('Gecko ID', Boolean(manifest.applications?.gecko?.id)));
}
// Check permissions
console.log(` Permissions: ${manifest.permissions.length} total`);
// Check content scripts
console.log(` Content scripts: ${manifest.content_scripts?.length || 0} configured`);
// Check polyfill inclusion
const polyfillPath = path.join(buildDir, 'src/polyfill/browser-polyfill.min.js');
const polyfillExists = fs.existsSync(polyfillPath);
console.log(` WebExtension polyfill: ${polyfillExists ? 'Present' : 'Missing'}`);
// Check if polyfill is included in content scripts
const hasPolyfillInContent = manifest.content_scripts?.[0]?.js?.includes('src/polyfill/browser-polyfill.min.js');
console.log(` Polyfill in content scripts: ${hasPolyfillInContent ? 'Yes' : 'No'}`);
// Check if polyfill is included in background (Firefox only)
results.push(check('Permissions', (manifest.permissions || []).length > 0));
results.push(check('Content scripts', (manifest.content_scripts || []).length > 0));
// The polyfill is copied from node_modules by build.js, so a missing file here
// means the build step silently skipped it.
const polyfillRelPath = 'src/polyfill/browser-polyfill.min.js';
results.push(check('WebExtension polyfill', fs.existsSync(path.join(buildDir, polyfillRelPath))));
results.push(check('Polyfill in content scripts',
Boolean(manifest.content_scripts?.[0]?.js?.includes(polyfillRelPath))));
if (browser === 'firefox') {
const hasPolyfillInBackground = manifest.background?.scripts?.includes('src/polyfill/browser-polyfill.min.js');
console.log(` Polyfill in background: ${hasPolyfillInBackground ? 'Yes' : 'No'}`);
results.push(check('Polyfill in background',
Boolean(manifest.background?.scripts?.includes(polyfillRelPath))));
}
console.log(`✅ ${browser} extension structure looks good!`);
return true;
const passed = results.every(Boolean);
console.log(passed
? `✅ ${browser} extension structure looks good!`
: `❌ ${browser} extension structure has failing checks`);
return passed;
} catch (error) {
console.error(`❌ Error testing ${browser} extension:`, error.message);
return false;
}
}
function testBackgroundScript(browser) {
console.log(`\n🔍 Testing ${browser} background script...`);
const scriptPath = `dist/${browser}/src/background/background.js`;
try {
const script = fs.readFileSync(scriptPath, 'utf8');
// Check for browser polyfill usage
const usesBrowserAPI = script.includes('browser.') || script.includes('globalThis.browser');
console.log(` Uses browser API: ${usesBrowserAPI ? 'Yes' : 'No'}`);
// Check for connection manager
const hasConnectionManager = script.includes('ConnectionManager');
console.log(` Has connection manager: ${hasConnectionManager ? 'Yes' : 'No'}`);
// Check for browser detection
const hasBrowserDetection = script.includes('browserInfo') || script.includes('isFirefox') || script.includes('isServiceWorker');
console.log(` Has browser detection: ${hasBrowserDetection ? 'Yes' : 'No'}`);
// Check for WebSocket management
const hasWebSocketManagement = script.includes('WebSocket') && script.includes('connect');
console.log(` Has WebSocket management: ${hasWebSocketManagement ? 'Yes' : 'No'}`);
console.log(`✅ ${browser} background script looks good!`);
return true;
} catch (error) {
console.error(`❌ Error testing ${browser} background script:`, error.message);
return false;
}
}
const usesBrowserAPI = (s) => s.includes('browser.') || s.includes('globalThis.browser');
function testContentScript(browser) {
console.log(`\n🔍 Testing ${browser} content script...`);
const scriptPath = `dist/${browser}/src/content/content.js`;
try {
const script = fs.readFileSync(scriptPath, 'utf8');
// Check for browser polyfill usage
const usesBrowserAPI = script.includes('browser.') || script.includes('globalThis.browser');
console.log(` Uses browser API: ${usesBrowserAPI ? 'Yes' : 'No'}`);
// Check for message handling
const hasMessageHandling = script.includes('onMessage') && script.includes('sendResponse');
console.log(` Has message handling: ${hasMessageHandling ? 'Yes' : 'No'}`);
console.log(`✅ ${browser} content script looks good!`);
return true;
} catch (error) {
console.error(`❌ Error testing ${browser} content script:`, error.message);
return false;
}
}
const SCRIPT_TESTS = [
{
label: 'background script',
relPath: 'src/background/background.js',
probes: [
['Uses browser API', usesBrowserAPI],
['Has connection manager', (s) => s.includes('ConnectionManager')],
['Has browser detection', (s) =>
s.includes('browserInfo') || s.includes('isFirefox') || s.includes('isServiceWorker')],
['Has WebSocket management', (s) => s.includes('WebSocket') && s.includes('connect')],
],
},
{
label: 'content script',
relPath: 'src/content/content.js',
probes: [
['Uses browser API', usesBrowserAPI],
['Has message handling', (s) => s.includes('onMessage') && s.includes('sendResponse')],
],
},
{
label: 'popup script',
relPath: 'src/popup/popup.js',
probes: [
['Uses browser API', usesBrowserAPI],
['Has API abstraction', (s) =>
s.includes('runtimeAPI') || s.includes('tabsAPI') || s.includes('storageAPI')],
],
},
];
function testScript(browser, { label, relPath, probes }) {
console.log(`\n🔍 Testing ${browser} ${label}...`);
function testPopupScript(browser) {
console.log(`\n🔍 Testing ${browser} popup script...`);
const scriptPath = `dist/${browser}/src/popup/popup.js`;
try {
const script = fs.readFileSync(scriptPath, 'utf8');
// Check for browser polyfill usage
const usesBrowserAPI = script.includes('browser.') || script.includes('globalThis.browser');
console.log(` Uses browser API: ${usesBrowserAPI ? 'Yes' : 'No'}`);
// Check for API abstraction
const hasAPIAbstraction = script.includes('runtimeAPI') || script.includes('tabsAPI') || script.includes('storageAPI');
console.log(` Has API abstraction: ${hasAPIAbstraction ? 'Yes' : 'No'}`);
console.log(`✅ ${browser} popup script looks good!`);
return true;
const script = fs.readFileSync(path.join(`dist/${browser}`, relPath), 'utf8');
const passed = probes
.map(([name, probe]) => check(name, probe(script)))
.every(Boolean);
console.log(passed
? `✅ ${browser} ${label} looks good!`
: `❌ ${browser} ${label} has failing checks`);
return passed;
} catch (error) {
console.error(`❌ Error testing ${browser} popup script:`, error.message);
console.error(`❌ Error testing ${browser} ${label}:`, error.message);
return false;
}
}
function runAllTests() {
console.log('🚀 Testing cross-browser extension compatibility...\n');
const browsers = ['chrome', 'firefox'];
let allPassed = true;
for (const browser of browsers) {
console.log(`\n🌐 Testing ${browser.toUpperCase()} extension:`);
console.log('='.repeat(40));
const tests = [
testManifestStructure,
testBackgroundScript,
testContentScript,
testPopupScript
];
for (const test of tests) {
if (!test(browser)) {
if (!testManifestStructure(browser)) {
allPassed = false;
}
for (const scriptTest of SCRIPT_TESTS) {
if (!testScript(browser, scriptTest)) {
allPassed = false;
}
}
}
console.log('\n' + '='.repeat(50));
if (allPassed) {
console.log('🎉 All tests passed! Cross-browser extension is ready.');
console.log('\n📦 Distribution packages:');
console.log(' Chrome: dist/opendia-chrome.zip');
console.log(' Firefox: dist/opendia-firefox.zip');
console.log('\n🧪 Manual testing:');
console.log(' 1. Chrome: Load dist/chrome in chrome://extensions');
console.log(' 2. Firefox: Load dist/firefox in about:debugging');
console.log(' 3. Both should connect to MCP server on localhost:5555/5556');
} else {
if (!allPassed) {
console.log('❌ Some tests failed. Please check the output above.');
process.exit(1);
}
console.log('🎉 All tests passed! Cross-browser extension is ready.');
console.log('\n📦 Distribution packages:');
console.log(' Chrome: dist/opendia-chrome.zip');
console.log(' Firefox: dist/opendia-firefox.zip');
console.log('\n🧪 Manual testing:');
console.log(' 1. Chrome: Load dist/chrome in chrome://extensions');
console.log(' 2. Firefox: Load dist/firefox in about:debugging');
console.log(' 3. Both should connect to MCP server on localhost:5555/5556');
}
// Run tests
runAllTests();
runAllTests();

View file

@ -1,4 +0,0 @@
# MCP Server Configuration
PORT=3000
HEALTH_PORT=3001
NODE_ENV=development

View file

@ -248,6 +248,45 @@ let wss = null;
let chromeExtensionSocket = null;
let availableTools = [];
// Open SSE response streams, so tools/list_changed can reach SSE clients too.
const sseClients = new Set();
// Notifications are only legal after the client has initialized, and only
// useful then: a client initializing later lists tools itself.
let clientInitialized = false;
// What the last tools/list would have returned, so a reconnecting extension
// re-registering the same tools doesn't emit a pointless notification.
let lastToolSignature = "";
function toolSignature(tools) {
return tools.map((t) => t.name).join(",");
}
// The extension connects independently of the MCP client, so the tool list can
// change after startup. Without this the client keeps whatever the first
// tools/list returned - typically the fallback schemas - for the whole session.
function notifyToolsListChanged() {
const signature = availableTools.length > 0 ? toolSignature(availableTools) : "";
if (!clientInitialized || signature === lastToolSignature) {
return;
}
lastToolSignature = signature;
const notification = JSON.stringify({
jsonrpc: "2.0",
method: "notifications/tools/list_changed",
});
if (!sseOnly) {
process.stdout.write(notification + "\n");
}
for (const res of sseClients) {
res.write(`data: ${notification}\n\n`);
}
console.error(
`📢 Sent tools/list_changed (${availableTools.length} tools, ${sseClients.size} SSE client(s))`
);
}
// Tool call tracking
const pendingCalls = new Map();
// Monotonic counter so concurrent tool calls can never share a call id.
@ -277,10 +316,13 @@ async function handleMCPRequest(request) {
console.error(
`MCP client initializing: ${params?.clientInfo?.name || "unknown"}`
);
clientInitialized = true;
// The extension may register after this point, so declare that the tool
// list can change; without it a client has no reason to ever re-list.
result = {
protocolVersion: "2024-11-05",
capabilities: {
tools: {},
tools: { listChanged: true },
},
serverInfo: {
name: "browser-mcp-server",
@ -323,6 +365,10 @@ async function handleMCPRequest(request) {
tools: getFallbackTools(),
};
}
// Record what the client now believes, so notifyToolsListChanged only
// fires when the answer would actually differ.
lastToolSignature =
availableTools.length > 0 ? toolSignature(availableTools) : "";
break;
case "tools/call":
@ -398,7 +444,7 @@ async function handleMCPRequest(request) {
}
}
// Enhanced tool result formatting with anti-detection support
// Tool result formatting
function formatToolResult(toolName, result) {
const metadata = {
tool: toolName,
@ -465,7 +511,7 @@ function formatToolResult(toolName, result) {
return formatPageStyleResult(result, metadata);
default:
// Legacy tools or unknown tools
// Tools without a dedicated formatter (get_bookmarks, add_bookmark)
return JSON.stringify(result, null, 2);
}
}
@ -480,9 +526,14 @@ function formatPageAnalyzeResult(result, metadata) {
`Found ${result.elements.length} relevant elements using ${result.method}:${platformInfo}\n\n` +
result.elements
.map((el) => {
const readyStatus = el.ready ? "✅ Ready" : "⚠️ Not ready";
const stateInfo = el.state === "disabled" ? " (disabled)" : "";
return `• ${el.name} (${el.type}) - Confidence: ${el.conf}% ${readyStatus}${stateInfo}\n Element ID: ${el.id}`;
// The discover phase emits type/ready/state at the top level; the
// detailed phase emits a fingerprint and nests state under meta.
const ready = el.ready ?? el.meta?.state?.interaction_ready;
const disabled = el.state === "disabled" || el.meta?.state?.disabled;
const type = el.type || el.fp || "element";
const readyStatus = ready ? "✅ Ready" : "⚠️ Not ready";
const stateInfo = disabled ? " (disabled)" : "";
return `• ${el.name} (${type}) - Confidence: ${el.conf}% ${readyStatus}${stateInfo}\n Element ID: ${el.id}`;
})
.join("\n\n");
return `${summary}\n\n${JSON.stringify(metadata, null, 2)}`;
@ -500,7 +551,9 @@ function formatPageAnalyzeResult(result, metadata) {
}
function formatContentExtractionResult(result, metadata) {
const contentSummary = `Extracted ${result.content_type} content using ${result.method}:\n\n`;
// summarize:true (the default) reports extraction_method; summarize:false reports method.
const method = result.method || result.extraction_method;
const contentSummary = `Extracted ${result.content_type} content using ${method}:\n\n`;
if (result.content) {
// Check if this is full content extraction (summarize=false) or summary
// If it's a content object with properties, show full content
@ -529,7 +582,6 @@ function formatContentExtractionResult(result, metadata) {
2
)}`;
} else if (result.summary) {
// Enhanced summarized content response
const summaryText = formatContentSummary(
result.summary,
result.content_type
@ -601,7 +653,6 @@ function formatElementClickResult(result, metadata) {
}
function formatElementFillResult(result, metadata) {
// Enhanced formatting for anti-detection bypass methods
const methodEmojis = {
twitter_direct_bypass: "🐦 Twitter Direct Bypass",
linkedin_direct_bypass: "💼 LinkedIn Direct Bypass",
@ -717,13 +768,11 @@ function formatScrollResult(result, metadata) {
if (result.amount && result.amount !== "custom") {
summary += ` (${result.amount})`;
} else if (result.pixels) {
summary += ` (${result.pixels}px)`;
} else if (result.requested_pixels) {
summary += ` (${result.requested_pixels}px)`;
}
// These read the field names scrollPage actually returns. They previously
// read scroll_position/wait_time, which it never emits, so the position and
// wait were silently dropped from every scroll result.
// Field names must match what scrollPage returns (new_position, wait_after).
if (result.new_position) {
summary += `\n📍 New position: x=${result.new_position.x}, y=${result.new_position.y}`;
}
@ -905,7 +954,6 @@ ${JSON.stringify(metadata, null, 2)}`;
}
function formatPageStyleResult(result, metadata) {
const successIcon = result.success ? '✅' : '❌';
const statusText = result.success ? 'successfully applied' : 'failed to apply';
let summary = `🎨 Page styling ${statusText}\n\n`;
@ -954,7 +1002,7 @@ function formatPageStyleResult(result, metadata) {
return `${summary}\n\n${JSON.stringify(metadata, null, 2)}`;
}
// Enhanced fallback tools when extension is not connected
// Tool list served while the extension is not connected
function getFallbackTools() {
return [
{
@ -1597,6 +1645,7 @@ function setupWebSocketHandlers() {
.map((t) => t.name)
.join(", ")}`
);
notifyToolsListChanged();
} else if (message.type === "ping") {
// Respond to ping with pong
ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
@ -1619,6 +1668,7 @@ function setupWebSocketHandlers() {
console.error("Browser Extension disconnected");
chromeExtensionSocket = null;
availableTools = []; // Clear tools when extension disconnects
notifyToolsListChanged();
// Reject any in-flight tool calls immediately so the MCP client
// sees a real error instead of hanging for up to 30s waiting on
// a socket that's gone.
@ -1667,6 +1717,9 @@ app.route('/sse')
version: SERVER_VERSION
})}\n\n`);
// Registered so server-initiated notifications reach this stream.
sseClients.add(res);
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
res.write(`data: ${JSON.stringify({
@ -1677,6 +1730,7 @@ app.route('/sse')
req.on('close', () => {
clearInterval(heartbeat);
sseClients.delete(res);
console.error('SSE client disconnected');
});
@ -1816,7 +1870,7 @@ async function startServer() {
console.error(`✅ Ports resolved: WebSocket=${WS_PORT}, HTTP=${HTTP_PORT}`);
// Start HTTP server
const httpServer = app.listen(HTTP_PORT, HTTP_HOST, () => {
app.listen(HTTP_PORT, HTTP_HOST, () => {
console.error(`🌐 HTTP/SSE server running on ${HTTP_HOST}:${HTTP_PORT}`);
console.error(`🔌 Browser Extension connects on ws://localhost:${WS_PORT}`);
if (!isLoopbackHost(HTTP_HOST)) {