UNPKG

supapup

Version:

โšก Lightning-fast MCP browser dev tool. Navigate โ†’ Get instant structured data. No screenshots needed! Puppeteer: ๐Ÿ“ธ โ†’ CSS selectors โ†’ JS eval. Supapup: semantic IDs ready to use. 10x faster, 90% fewer tokens.

630 lines (629 loc) โ€ข 30 kB
import { DevToolsAgentPageGenerator } from './../generators/devtools-agent-page-generator.js'; // AgentPageScript removed - DevToolsAgentPageGenerator handles its own injection import { ContentExtractor } from './../generators/content-extractor.js'; import { PageSettleDetector } from './../core/page-settle-detector.js'; import { randomUUID } from 'crypto'; export class AgentTools { page = null; currentManifest = null; screenshotChunkData = null; agentPageChunkData = new Map(); browserTools = null; constructor() { } // Initialize with page and manifest from BrowserTools initialize(page, manifest, browserTools) { this.page = page; this.currentManifest = manifest; if (browserTools) { this.browserTools = browserTools; } } async executeAction(actionId, params = {}) { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } if (!this.currentManifest) { throw new Error('No agent page manifest available. Generate agent page first.'); } console.log(`๐ŸŽฏ Executing action: ${actionId}`, params); // Inject interaction script if not present await this.ensureInteractionScript(); // Check if we should wait for changes (default: true) const waitForChanges = params.waitForChanges !== false; if (!waitForChanges) { // Simple execution without waiting const result = await this.page.evaluate((args) => { const agentPage = window.__AGENT_PAGE__; if (!agentPage) { throw new Error('Agent page interaction not available'); } return agentPage.execute(args.actionId, args.params); }, { actionId, params }); console.log(`โœ… Action executed:`, result); return { content: [{ type: 'text', text: `โœ… Action completed: ${JSON.stringify(result)}` }] }; } // Simple and reliable navigation detection const originalUrl = await this.page.url(); let actionResult = null; try { // Execute the action actionResult = await this.page.evaluate((args) => { const agentPage = window.__AGENT_PAGE__; if (!agentPage) { throw new Error('Agent page interaction not available'); } return agentPage.execute(args.actionId, args.params); }, { actionId, params }); console.log(`โœ… Action executed:`, actionResult); } catch (error) { if (error.message.includes('Execution context was destroyed') || error.message.includes('Navigation')) { console.log('๐ŸŒ Navigation detected via execution context destruction'); actionResult = { success: true, actionId, action: 'navigation' }; } else { throw error; } } // Always check for navigation after action execution const finalUrl = await this.page.url(); const navigationOccurred = finalUrl !== originalUrl; if (navigationOccurred) { console.log(`๐ŸŒ Navigation detected via URL change: ${originalUrl} โ†’ ${finalUrl}`); } // If navigation occurred, wait for network idle and regenerate page if (navigationOccurred) { console.log('โณ Waiting for post-navigation network idle...'); try { await this.page.waitForNetworkIdle({ timeout: 8000 }); } catch (timeoutError) { console.log('โš ๏ธ Network idle timeout, proceeding...'); } // Generate new agent page after navigation const agentPage = await this.generateAgentPage(); return { content: [{ type: 'text', text: `โœ… Action completed: ${JSON.stringify(actionResult)}\n\n๐ŸŒ Page navigated\n\n๐Ÿ“„ Agent page updated:\n\n${agentPage}` }] }; } // Determine if this is a local file or web URL const isLocalFile = originalUrl.startsWith('file://'); if (isLocalFile) { // For local files: assume changes occur, wait 1 second, then regenerate console.log('๐Ÿ“ Local file detected - assuming DOM changes occur'); // Wait 1 second for immediate CSS/JS changes to settle await new Promise(resolve => setTimeout(resolve, 1000)); // Always regenerate page for local files const agentPage = await this.generateAgentPage(); return { content: [{ type: 'text', text: `โœ… Action completed: ${JSON.stringify(actionResult)}\n\n๐Ÿ”„ DOM changes assumed (local file)\n\n๐Ÿ“„ Agent page updated:\n\n${agentPage}` }] }; } // For web URLs: use existing network-based change detection console.log('๐ŸŒ Web URL detected - using network-based change detection'); // Wait a moment for any immediate changes await new Promise(resolve => setTimeout(resolve, 500)); // Check if URL changed (immediate navigation) let currentUrl = await this.page.url(); if (currentUrl !== originalUrl) { console.log(`๐ŸŒ Navigation detected: ${originalUrl} โ†’ ${currentUrl}`); await this.page.waitForNetworkIdle({ timeout: 5000 }).catch(() => { }); const agentPage = await this.generateAgentPage(); return { content: [{ type: 'text', text: `โœ… Action completed: ${JSON.stringify(actionResult)}\n\n๐ŸŒ Page navigated\n\n๐Ÿ“„ Agent page updated:\n\n${agentPage}` }] }; } // Use PageSettleDetector for DOM change detection on web URLs const detector = new PageSettleDetector(this.page); const settleResult = await detector.waitForPageSettle({ domIdleTime: 300, networkIdleTime: 300, globalTimeout: 2000, ignoredSelectors: ['.ad-banner', '.clock', '[data-timestamp]'], ignoredAttributes: ['data-timestamp', 'data-time'] }); console.log(`๐Ÿ“Š Settlement result:`, { settled: settleResult.settled, hasChanges: settleResult.hasChanges, navigated: settleResult.navigated, dialogHandled: settleResult.dialogHandled, duration: `${settleResult.duration}ms` }); // IMPORTANT: Check URL again after settle detection currentUrl = await this.page.url(); const urlChanged = currentUrl !== originalUrl; if (urlChanged) { console.log(`๐ŸŒ Navigation detected after settle: ${originalUrl} โ†’ ${currentUrl}`); } // Always regenerate page if any changes detected OR URL changed if (settleResult.hasChanges || settleResult.navigated || settleResult.dialogHandled || urlChanged) { console.log(`๐Ÿ“„ Changes detected, regenerating agent page...`); // Re-generate the agent page with new content const agentPage = await this.generateAgentPage(); let responseText = `โœ… Action completed: ${JSON.stringify(actionResult)}\n\n`; // Provide detailed change information if (urlChanged || settleResult.navigated) { responseText += `๐ŸŒ Page navigated\n`; } if (settleResult.changes.domMutations) { responseText += `๐Ÿ”„ DOM changes detected (${settleResult.changes.newElements} added, ${settleResult.changes.removedElements} removed)\n`; } if (settleResult.dialogHandled) { responseText += `๐Ÿ’ฌ Dialog handled: ${settleResult.changes.dialogType}\n`; } responseText += `\n๐Ÿ“„ Agent page updated:\n\n${agentPage}`; return { content: [{ type: 'text', text: responseText }] }; } else { // No changes detected return { content: [{ type: 'text', text: `โœ… Action completed: ${JSON.stringify(actionResult)}\n\nโธ๏ธ No page changes detected` }] }; } } catch (error) { console.error('โŒ Action execution failed:', error); return { content: [{ type: 'text', text: `โŒ Action failed: ${error.message}` }] }; } } async discoverActions() { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } console.log('๐Ÿ” Discovering available actions...'); // Generate agent page which also updates currentManifest await this.generateAgentPage(); const manifest = this.currentManifest; if (!manifest || !manifest.elements || manifest.elements.length === 0) { return { content: [{ type: 'text', text: 'No interactive elements found on the current page.' }] }; } // Create action list const actions = manifest.elements.map((element) => { const params = element.action === 'fill' ? `, params: {value: "text"}` : ''; return `execute_action({actionId: "${element.id}"${params}}) โ†’ ${element.description}`; }); const actionsList = actions.join('\n'); return { content: [{ type: 'text', text: `Available actions (${actions.length}):\n${actionsList}` }] }; } catch (error) { console.error('โŒ Error discovering actions:', error); return { content: [{ type: 'text', text: `โŒ Error discovering actions: ${error.message}` }] }; } } async generatePage(enhanced = true, mode = 'auto') { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } console.log(`๐Ÿ”„ Generating agent page (enhanced: ${enhanced}, mode: ${mode})...`); // Ensure agent page generator is injected await this.ensureInteractionScript(); // Generate agent page which also updates currentManifest const agentPage = await this.generateAgentPage(); return { content: [{ type: 'text', text: agentPage }] }; } catch (error) { console.error('โŒ Error generating agent page:', error); return { content: [{ type: 'text', text: `โŒ Error generating agent page: ${error.message}` }] }; } } async remapPage(timeout = 5000, waitForSelector) { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } console.log('๐Ÿ”„ Remapping page after changes...'); // Wait for selector if provided if (waitForSelector) { console.log(`โณ Waiting for selector: ${waitForSelector}`); try { await this.page.waitForSelector(waitForSelector, { timeout }); } catch (timeoutError) { console.log('โš ๏ธ Timeout waiting for selector, proceeding with remap...'); } } // Wait a bit for DOM to settle await new Promise(resolve => setTimeout(resolve, 500)); // Re-inject and regenerate const agentPage = await this.generateAgentPage(); return { content: [{ type: 'text', text: `๐Ÿ”„ Page remapped\n\n${agentPage}` }] }; } catch (error) { console.error('โŒ Error remapping page:', error); return { content: [{ type: 'text', text: `โŒ Error remapping page: ${error.message}` }] }; } } async waitForChanges(timeout = 5000, waitForNavigation = false, waitForSelector, waitForText) { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } console.log(`โณ Waiting for changes (timeout: ${timeout}ms)...`); // Use PageSettleDetector for comprehensive change detection const detector = new PageSettleDetector(this.page); const settleResult = await detector.waitForPageSettle({ domIdleTime: 500, networkIdleTime: 500, globalTimeout: timeout, waitForSelector: waitForSelector, waitForFunction: waitForText ? `() => document.body.textContent?.includes("${waitForText}")` : undefined, ignoredSelectors: ['.ad-banner', '.clock', '[data-timestamp]'], ignoredAttributes: ['data-timestamp', 'data-time'] }); console.log(`๐Ÿ“Š Settlement result:`, { settled: settleResult.settled, hasChanges: settleResult.hasChanges, navigated: settleResult.navigated, duration: `${settleResult.duration}ms` }); // Re-generate agent page const agentPage = await this.generateAgentPage(); let responseText = 'โœ… Changes detected and page updated\n\n'; if (settleResult.navigated) { responseText = '๐ŸŒ Navigation detected\n\n'; } else if (settleResult.changes.domMutations) { responseText = `๐Ÿ”„ DOM changes detected (${settleResult.changes.newElements} added, ${settleResult.changes.removedElements} removed)\n\n`; } responseText += agentPage; return { content: [{ type: 'text', text: responseText }] }; } catch (error) { console.error('โŒ Error waiting for changes:', error); return { content: [{ type: 'text', text: `โŒ Timeout or error waiting for changes: ${error.message}` }] }; } } async getPageChunk(page, maxElements = 150) { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } console.log(`๐Ÿ“„ Getting page chunk ${page} (max ${maxElements} elements)...`); // For content-based chunking, use the new DevToolsAgentPageGenerator const html = await this.page.evaluate(() => document.documentElement.outerHTML); const generator = new DevToolsAgentPageGenerator(this.page); const result = await generator.generateAgentPage(); const pageSize = 20000; // Characters per page const startPos = (page - 1) * pageSize; const endPos = startPos + pageSize; const chunk = result.content.slice(startPos, endPos); if (!chunk.trim()) { return { content: [{ type: 'text', text: `โŒ Page ${page} is empty or doesn't exist` }] }; } const totalPages = Math.ceil(result.content.length / pageSize); let response = `๐Ÿ“„ Page ${page} of ${totalPages}\n`; response += `๐Ÿ“Š Elements on this page: ${result.elements ? result.elements.length : 0}\n\n`; response += chunk; return { content: [{ type: 'text', text: response }] }; } catch (error) { console.error('โŒ Error getting page chunk:', error); return { content: [{ type: 'text', text: `โŒ Error getting page chunk: ${error.message}` }] }; } } async readContent(format = 'markdown', page, pageSize = 20000, maxElements = 100) { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } console.log(`๐Ÿ“– Reading page content (format: ${format}, page: ${page || 'all'})...`); const url = await this.page.url(); const html = await this.page.evaluate(() => document.documentElement.outerHTML); const result = ContentExtractor.extractReadableContent(html, url, { maxElements }); if (page) { // Return specific page const startPos = (page - 1) * pageSize; const endPos = startPos + pageSize; const chunk = result.content.slice(startPos, endPos); if (!chunk.trim()) { return { content: [{ type: 'text', text: `โŒ Page ${page} is empty or doesn't exist` }] }; } const totalPages = Math.ceil(result.content.length / pageSize); let response = `๐Ÿ“– Content Page ${page} of ${totalPages}\n`; response += `๐Ÿ“ Total content: ${result.content.length} characters\n\n`; response += chunk; return { content: [{ type: 'text', text: response }] }; } // Return full content with pagination info const totalPages = Math.ceil(result.content.length / pageSize); let response = `๐Ÿ“– Page Content (${format})\n`; response += `๐Ÿ“ ${result.content.length} characters`; if (totalPages > 1) { response += ` across ${totalPages} pages`; response += `\n๐Ÿ’ก Use agent_read_content({page: N}) to get specific pages`; } response += `\n\n${result.content}`; return { content: [{ type: 'text', text: response }] }; } catch (error) { console.error('โŒ Error reading content:', error); return { content: [{ type: 'text', text: `โŒ Error reading content: ${error.message}` }] }; } } async getPageState() { try { if (!this.page) { throw new Error('No page available. Navigate to a page first.'); } console.log('๐Ÿ“Š Getting current page state...'); const url = await this.page.url(); const title = await this.page.title(); return { content: [{ type: 'text', text: `Current page: ${title}\nURL: ${url}\nManifest available: ${!!this.currentManifest}` }] }; } catch (error) { console.error('โŒ Error getting page state:', error); return { content: [{ type: 'text', text: `โŒ Error getting page state: ${error.message}` }] }; } } async getAgentPageChunk(id, chunk) { try { // Try local chunk data first let chunkData = this.agentPageChunkData.get(id); // If not found locally, check BrowserTools if (!chunkData && this.browserTools) { const browserChunks = this.browserTools.getAgentPageChunkData(); chunkData = browserChunks.get(id); } if (!chunkData) { throw new Error(`Agent page chunk ID not found: ${id}`); } if (chunk < 1 || chunk > chunkData.totalChunks) { throw new Error(`Invalid chunk number: ${chunk}. Available chunks: 1-${chunkData.totalChunks}`); } const pageContent = chunkData.chunks[chunk - 1]; // Validate chunk data before returning if (!pageContent || pageContent.length === 0) { throw new Error(`Agent page chunk ${chunk} is empty or invalid. This may be due to a chunking error.`); } // Get element range for this chunk const elementRange = chunkData.metadata.elementRanges[chunk - 1]; const elementsInChunk = elementRange ? (elementRange.end - elementRange.start + 1) : 0; // Navigation instructions const prevChunk = Math.max(1, chunk - 1); const nextChunk = Math.min(chunkData.totalChunks, chunk + 1); const navigationText = chunk === 1 ? `\nโญ๏ธ Next: get_agent_page_chunk({id: "${id}", chunk: ${nextChunk}})` : chunk === chunkData.totalChunks ? `\nโฎ๏ธ Previous: get_agent_page_chunk({id: "${id}", chunk: ${prevChunk}})` : `\nโฎ๏ธ Previous: get_agent_page_chunk({id: "${id}", chunk: ${prevChunk}})\nโญ๏ธ Next: get_agent_page_chunk({id: "${id}", chunk: ${nextChunk}})`; let response = `๐Ÿ“„ Agent Page Chunk ${chunk} of ${chunkData.totalChunks}\n`; response += `๐Ÿ“ URL: ${chunkData.metadata.url}\n`; response += `๐Ÿ“Š Elements in this chunk: ${elementsInChunk}\n`; response += `๐Ÿ“‹ Chunk ID: ${id}${navigationText}\n\n`; response += pageContent; return { content: [{ type: 'text', text: response }] }; } catch (error) { console.error('โŒ Error getting agent page chunk:', error); return { content: [{ type: 'text', text: `โŒ Error getting agent page chunk: ${error.message}` }] }; } } // Private helper methods async ensureInteractionScript() { try { // DevToolsAgentPageGenerator handles its own interaction script injection } catch (error) { console.error('โŒ Error ensuring interaction script:', error); throw new Error(`Failed to inject interaction script: ${error.message}`); } } async executeActionWithNavigationHandling(actionId, params) { // Set up navigation listeners before executing the action const navigationPromise = new Promise((resolve) => { const timeout = setTimeout(() => resolve(false), 5000); // 5 second timeout const navigationHandler = () => { clearTimeout(timeout); this.page?.off('framenavigated', navigationHandler); resolve(true); }; this.page?.on('framenavigated', navigationHandler); }); // Execute the action const actionPromise = this.page.evaluate((args) => { const agentPage = window.__AGENT_PAGE__; if (!agentPage) { throw new Error('Agent page interaction not available'); } return agentPage.execute(args.actionId, args.params); }, { actionId, params }); // Race between action completion and navigation const result = await Promise.race([ actionPromise, navigationPromise.then(navigated => { if (navigated) { // Navigation occurred, return a success result return { success: true, actionId, action: 'navigation' }; } // No navigation, wait for action result return actionPromise; }) ]); return result; } // Removed - DevToolsAgentPageGenerator is now the single source of truth async generateAgentPage() { try { if (!this.page) throw new Error('Page not initialized'); // Single source of truth - DevToolsAgentPageGenerator const generator = new DevToolsAgentPageGenerator(this.page); const result = await generator.generateAgentPage(); // Store elements for other methods that need them this.currentManifest = { elements: result.elements.map((el) => ({ id: el.id, type: el.type, action: el.action, description: el.label || el.text || `${el.action} ${el.type}`, context: el.label || '' })), summary: `Found ${result.elements.length} interactive elements`, url: result.url, title: result.title, content: result.content }; // Check if content needs to be chunked (20k chars is roughly 5k tokens) const TOKEN_LIMIT = 20000; // Conservative limit for agent pages if (result.content.length > TOKEN_LIMIT) { console.log(`๐Ÿ“ฆ Agent page too large (${result.content.length} chars), creating chunks...`); // Create chunks based on logical sections const chunks = this.chunkAgentPage(result.content, result.elements); // Store chunked data const chunkId = randomUUID(); this.agentPageChunkData.set(chunkId, { id: chunkId, totalChunks: chunks.length, chunks: chunks.map(c => c.content), metadata: { url: result.url, title: result.title, totalElements: result.elements.length, timestamp: new Date(), elementRanges: chunks.map(c => ({ start: c.startElement, end: c.endElement })) } }); // Return first chunk with instructions const url = await this.page.url(); let response = `โœ… Navigation successful\n๐Ÿ“ URL: ${url}\n\n`; response += `โš ๏ธ Large page detected (${result.content.length} characters)\n`; response += `๐Ÿ“ฆ Content split into ${chunks.length} chunks\n`; response += `๐Ÿ“‹ Chunk ID: ${chunkId}\n`; response += `๐Ÿ” Use get_agent_page_chunk({id: "${chunkId}", chunk: 2}) for next chunk\n\n`; response += chunks[0].content; return response; } // Return the complete agent page with navigation header const url = await this.page.url(); return `โœ… Navigation successful\n๐Ÿ“ URL: ${url}\n\n${result.content}`; } catch (error) { console.error('โŒ Agent page generation failed:', error.message); throw new Error(`Agent page generation failed: ${error.message}`); } } chunkAgentPage(content, elements) { const chunks = []; const CHUNK_SIZE = 18000; // Leave room for headers // Split content by major sections (forms, navigation, etc.) const sections = content.split(/\n(?=## )/); // Split on section headers let currentChunk = ''; let currentStartElement = 0; let currentEndElement = 0; let elementIndex = 0; for (const section of sections) { // Count elements in this section const sectionElementCount = (section.match(/\[\w+-[\w-]+\]/g) || []).length; // If adding this section would exceed limit, save current chunk if (currentChunk && (currentChunk.length + section.length > CHUNK_SIZE)) { chunks.push({ content: currentChunk.trim(), startElement: currentStartElement, endElement: currentEndElement }); currentChunk = ''; currentStartElement = elementIndex; } currentChunk += (currentChunk ? '\n\n' : '') + section; currentEndElement = elementIndex + sectionElementCount - 1; elementIndex += sectionElementCount; // If current chunk is getting large, save it if (currentChunk.length > CHUNK_SIZE) { chunks.push({ content: currentChunk.trim(), startElement: currentStartElement, endElement: currentEndElement }); currentChunk = ''; currentStartElement = elementIndex; } } // Add remaining content if (currentChunk.trim()) { chunks.push({ content: currentChunk.trim(), startElement: currentStartElement, endElement: Math.max(currentEndElement, elements.length - 1) }); } return chunks; } // Utility method to clean up old chunks cleanupOldChunks(maxAge = 3600000) { const now = Date.now(); for (const [id, chunk] of this.agentPageChunkData.entries()) { if (now - chunk.metadata.timestamp.getTime() > maxAge) { this.agentPageChunkData.delete(id); console.log(`๐Ÿงน Cleaned up old agent page chunks: ${id}`); } } } }