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
JavaScript
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}`);
}
}
}
}