UNPKG

chromancer

Version:

A powerful command-line interface for automating Chrome browser using Playwright. Perfect for web scraping, automation, testing, and browser workflows.

991 lines (969 loc) • 68.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const core_1 = require("@oclif/core"); const base_js_1 = require("../base.js"); const claude_js_1 = require("../utils/claude.js"); const workflow_executor_js_1 = require("../utils/workflow-executor.js"); const workflow_storage_js_1 = require("../utils/workflow-storage.js"); const dom_inspector_js_1 = require("../utils/dom-inspector.js"); const run_log_js_1 = require("../utils/run-log.js"); const dom_digest_js_1 = require("../utils/dom-digest.js"); const yaml = tslib_1.__importStar(require("yaml")); const prompts_1 = require("@inquirer/prompts"); class AI extends base_js_1.BaseCommand { runLogManager; digestCollector; static description = "Natural-language AI assistant - turns English instructions into browser automation workflows"; static examples = [ '<%= config.bin %> <%= command.id %> "Click the login button and wait for #dashboard"', '<%= config.bin %> <%= command.id %> --dry-run "Scroll to 80% of the page"', '<%= config.bin %> <%= command.id %> --no-interactive "Take a screenshot" # Skip feedback loop', ]; static flags = { ...base_js_1.BaseCommand.baseFlags, "dry-run": core_1.Flags.boolean({ char: "d", description: "Show generated workflow but do NOT execute it", default: false, }), interactive: core_1.Flags.boolean({ description: "Enable interactive feedback loop after execution", default: true, allowNo: true, }), "auto-inspect": core_1.Flags.boolean({ description: "Automatically inspect DOM when selectors fail", default: true, }), "early-bailout": core_1.Flags.boolean({ description: "Stop execution immediately when a step fails", default: true, allowNo: true, }), auto: core_1.Flags.boolean({ description: "Automatically apply AI's favorite suggestion and retry until success", default: false, }), debug: core_1.Flags.boolean({ description: "Enable debug logging for terminal states and interactions", default: false, }), }; static args = { instruction: core_1.Args.string({ description: "English instruction for Claude", required: true, }), }; storage = new workflow_storage_js_1.WorkflowStorage(); attempts = []; constructor(argv, config) { super(argv, config); this.runLogManager = new run_log_js_1.RunLogManager(); // Increase max listeners to prevent warnings when using multiple prompts // @inquirer/prompts adds listeners for each prompt call process.setMaxListeners(0); // 0 = unlimited } async stabilizeTerminal(debug = false) { // Since we're using @inquirer/prompts everywhere, we just need minimal cleanup // Inquirer handles all the terminal state management for us if (debug) { this.log('\n[DEBUG] stabilizeTerminal() called - simplified version'); } // Ensure cursor is visible process.stdout.write('\x1b[?25h'); // Clear any residual line content process.stdout.write('\r\x1b[K'); // Small delay to let any pending output complete await new Promise(resolve => setTimeout(resolve, 10)); } async safePrompt(promptFn) { try { return await promptFn(); } catch (error) { // Handle user interruption gracefully if (error instanceof Error && error.message.includes('User force closed')) { this.log('\n\nāœ‹ Operation cancelled by user'); process.exit(0); } throw error; } } async run() { const { args, flags } = await this.parse(AI); // Handle exit errors from @inquirer/prompts gracefully process.on('uncaughtException', (error) => { if (error instanceof Error && error.name === 'ExitPromptError') { this.log('\n\nāœ‹ Operation cancelled'); process.exit(0); } else { // Rethrow unknown errors throw error; } }); // Initial attempt await this.attemptWorkflow(args.instruction, flags); // Ensure process exits cleanly after workflow completion process.exit(0); } async attemptWorkflow(instruction, flags, previousAttempts) { // Quick DOM scan if we're already connected to a page let domQuickScan; if (this.page) { try { const ora = (await Promise.resolve().then(() => tslib_1.__importStar(require('ora')))).default; const quickScanSpinner = ora('šŸ” Scanning page elements...').start(); const inspector = new dom_inspector_js_1.DOMInspector(this.page); const inspection = await inspector.inspectForDataExtraction(''); // Get page context const pageUrl = this.page.url(); const pageTitle = await this.page.title(); // Check for common issues const pageChecks = await this.page.evaluate(() => { const checks = { hasModals: !!document.querySelector('[role="dialog"], .modal, .popup, [class*="modal"][style*="display: block"]'), hasOverlay: !!document.querySelector('.overlay:not([style*="display: none"]), .backdrop:not([style*="display: none"])'), hasLoader: !!document.querySelector('.loader:not([style*="display: none"]), .loading:not([style*="display: none"]), .spinner:not([style*="display: none"])'), hasAlerts: !!document.querySelector('[role="alert"], .alert, .error, .warning'), hasIframes: document.querySelectorAll('iframe').length, formCount: document.querySelectorAll('form').length, disabledButtons: Array.from(document.querySelectorAll('button:disabled, input[type="submit"]:disabled')).length, readonlyInputs: Array.from(document.querySelectorAll('input[readonly], textarea[readonly]')).length }; // Get any visible error messages const errorElements = Array.from(document.querySelectorAll('.error, .alert-danger, [class*="error"]:not([style*="display: none"])')); checks.errorMessages = errorElements.slice(0, 3).map(el => el.textContent?.trim()).filter(Boolean); return checks; }); // Format interactive elements for Claude const elements = []; // Add page context elements.push(`PAGE CONTEXT:`); elements.push(` URL: ${pageUrl}`); elements.push(` Title: ${pageTitle}`); // Add warnings about potential issues if (pageChecks.hasModals || pageChecks.hasOverlay || pageChecks.hasLoader) { elements.push(`\nāš ļø WARNINGS:`); if (pageChecks.hasModals) elements.push(` - Modal/dialog detected - may block interactions`); if (pageChecks.hasOverlay) elements.push(` - Overlay detected - may block clicks`); if (pageChecks.hasLoader) elements.push(` - Loading indicator detected - page may still be loading`); } if (pageChecks.errorMessages && pageChecks.errorMessages.length > 0) { elements.push(`\nāŒ ERROR MESSAGES:`); pageChecks.errorMessages.forEach((msg) => { elements.push(` - "${msg}"`); }); } // Add visible buttons if (inspection.structure.buttons.length > 0) { elements.push('\nBUTTONS:'); inspection.structure.buttons.slice(0, 10).forEach(btn => { elements.push(` - ${btn.selector}: "${btn.text}"`); }); if (pageChecks.disabledButtons > 0) { elements.push(` āš ļø ${pageChecks.disabledButtons} disabled button(s) detected`); } } // Add inputs with visibility info const visibleInputs = inspection.structure.inputs.filter(i => i.visible !== false); const hiddenInputs = inspection.structure.inputs.filter(i => i.visible === false); if (visibleInputs.length > 0 || hiddenInputs.length > 0) { elements.push('\nINPUTS:'); visibleInputs.slice(0, 10).forEach(input => { const desc = input.placeholder || input.name || input.type; elements.push(` - ${input.selector}: ${desc} (${input.type})`); }); if (hiddenInputs.length > 0) { elements.push(` āš ļø ${hiddenInputs.length} hidden input(s) - may need to click something to reveal`); } if (pageChecks.readonlyInputs > 0) { elements.push(` āš ļø ${pageChecks.readonlyInputs} readonly input(s) detected`); } } // Add info about forms if (pageChecks.formCount > 0) { elements.push(`\nšŸ“ FORMS: ${pageChecks.formCount} form(s) detected`); } // Add hidden element triggers if found if (inspection.visibility?.revealTriggers?.length > 0) { elements.push('\nšŸ”“ TO REVEAL HIDDEN ELEMENTS:'); inspection.visibility.revealTriggers.slice(0, 3).forEach(trigger => { elements.push(` - Click "${trigger.triggerSelector}" to ${trigger.action}`); }); } // Add links if relevant if (inspection.structure.links.length > 0) { elements.push('\nLINKS:'); inspection.structure.links.slice(0, 5).forEach(link => { elements.push(` - ${link.selector}: "${link.text}"`); }); } // Add iframe warning if (pageChecks.hasIframes > 0) { elements.push(`\nāš ļø ${pageChecks.hasIframes} iframe(s) detected - elements inside iframes need special handling`); } if (elements.length > 0) { domQuickScan = elements.join('\n'); } quickScanSpinner.succeed('šŸ” Page scan complete'); } catch (error) { // Ignore scan errors } } // Collect DOM digest if we're already connected and have failures let domDigest; if (this.page && previousAttempts && previousAttempts.length > 0) { const lastAttempt = previousAttempts[previousAttempts.length - 1]; if (lastAttempt.result && this.hasEmptyDataExtraction(lastAttempt.result)) { try { if (!this.digestCollector) { this.digestCollector = new dom_digest_js_1.DOMDigestCollector(this.page); } const digest = await this.digestCollector.collect(); domDigest = this.digestCollector.formatForClaude(digest); } catch (error) { // Ignore digest collection errors } } } // Build system prompt with context from previous attempts const systemPrompt = this.buildSystemPrompt(previousAttempts, domDigest, domQuickScan); // Structure the instruction based on whether this is a retry let structuredInstruction = ''; if (previousAttempts && previousAttempts.length > 0) { // Tell Claude the earlier guidance FAILED and must be replaced structuredInstruction = ` The previous instructions did NOT work - throw them away. Write a BRAND-NEW YAML workflow that satisfies the user request below using what you learned from the runtime output. <user-request> ${instruction} </user-request>`; } else { structuredInstruction = `User request:\n${instruction}`; } const fullPrompt = `${systemPrompt}\n\n${structuredInstruction}`; const ora = (await Promise.resolve().then(() => tslib_1.__importStar(require('ora')))).default; const claudeSpinner = ora('šŸ¤– Asking Claude...').start(); try { const raw = await (0, claude_js_1.askClaude)(fullPrompt); claudeSpinner.succeed('šŸ¤– Claude responded'); const yamlText = this.cleanYamlOutput(raw); // Validate YAML this.validateYaml(yamlText); if (flags["dry-run"] || flags.verbose) { this.log("\n--- GENERATED WORKFLOW ---\n"); this.log(yamlText); this.log("\n--------------------------\n"); } if (flags["dry-run"]) return; // Execute workflow const result = await this.executeWorkflow(yamlText, flags, instruction); // Grab a verbatim, line-by-line dump for Claude const fullOutput = result.steps .map(s => { const status = s.success ? 'āœ…' : 'āŒ'; const details = s.output ?? s.error ?? 'No output'; return `[${status}] Step ${s.stepNumber} - ${s.command}: ${details}`; }) .join('\n'); // Store this attempt const attempt = { prompt: instruction, yaml: yamlText, result, fullOutput }; this.attempts.push(attempt); // Show execution summary this.showExecutionSummary(result); // If interactive mode, always verify results with Claude if (flags.interactive) { const ora5 = (await Promise.resolve().then(() => tslib_1.__importStar(require('ora')))).default; const verifySpinner = ora5('šŸ” Verifying results with AI...').start(); const verification = await this.verifyResults(instruction, attempt, result); verifySpinner.succeed('šŸ” AI Verification complete'); // Show verification to user this.log(verification.analysis); // Handle next steps based on verification if (verification.success) { this.log(`\nāœ… ${verification.reason}`); // In auto mode, just save and exit on success if (flags.auto) { this.log('\nšŸŽ‰ Auto mode: Workflow succeeded!'); await this.promptSaveWorkflow(instruction, yamlText, false, flags.debug); process.exit(0); } else { await this.handleSuccessfulWorkflow(instruction, yamlText, flags); } } else { this.log(`\nāš ļø ${verification.reason}`); await this.handleFeedbackLoop(instruction, attempt, flags, verification); } } } catch (error) { claudeSpinner.fail('šŸ¤– Claude request failed'); const errorMessage = error instanceof Error ? error.message : String(error); this.error(`Error: ${errorMessage}`); } } buildSystemPrompt(previousAttempts, domDigest, domQuickScan) { let prompt = `You are an expert Chromancer automation engineer. Convert the user's natural language instruction into a valid YAML workflow. OUTPUT RULES: 1. Return ONLY valid YAML - no markdown, no explanations, no comments 2. Start immediately with a dash (-) character 3. Use array format with one command per step 4. Each step must be a single key-value pair 5. Use proper YAML indentation (2 spaces) 6. DO NOT include any text before or after the YAML AVAILABLE COMMANDS: - navigate/goto: Visit a URL - click: Click an element (selector or {selector, button, clickCount, force: true}) - type: Type text ({selector, text} or "selector text") - wait: Wait for element/URL ({selector, timeout} or {url, timeout} or {message: "custom message"} for user input) - screenshot: Take screenshot (filename or {path, fullPage}) - scroll: Scroll page ({to: percentage} or {selector}) - evaluate: Run JavaScript ({script} or script string) - USE THIS FOR DATA EXTRACTION/SCRAPING - hover: Hover over element (selector) - select: Select dropdown option ({selector, value/label/index}) - fill: Fill form field ({selector, value}) - assert: Assert condition ({selector, text/value/visible}) - keypress: Press keyboard key (key string like "Enter", "Tab", "Escape") - checkpoint: Create a checkpoint for branching ({name: "checkpoint name"} or just "name") IMPORTANT DATA EXTRACTION RULES: - When user asks to "scrape", "grab", "extract", or "get" data, ALWAYS use evaluate - The evaluate script should return the data (arrays or objects work best) - The script must be a valid JavaScript expression that returns a value - Examples: - Simple text: - evaluate: "document.querySelector('h1').textContent" - Text array: - evaluate: "Array.from(document.querySelectorAll('h1')).map(el => el.textContent.trim())" - For complex scripts use the script format: - evaluate: script: | Array.from(document.querySelectorAll('.item')).map(el => ({ title: el.querySelector('h2').textContent, link: el.querySelector('a').href })) - The data will be automatically displayed and saved to a file - Format (JSON/CSV/text) is handled automatically based on user request VISIBILITY AND INTERACTION PATTERNS: - If you encounter "element is not visible" errors: 1. The element might be hidden until another action reveals it 2. Look for related buttons/icons that might toggle visibility 3. Try hovering over parent elements that might reveal hidden content 4. Consider if the element appears after page interactions - Learn from error messages - they often indicate what went wrong: - "not visible" = element exists but is hidden - "timeout exceeded" = element doesn't exist or selector is wrong - "multiple elements" = selector is too broad - Adapt your approach based on previous attempts and error patterns SEARCH RESULT EXTRACTION TIPS: - Google search results are typically in '.g' containers - News articles often have dates/timestamps and source names - Skip "People also ask" boxes and featured snippets at the top - For news articles specifically, look for: - Elements containing dates (e.g., "2 hours ago", "Dec 13") - Elements with news source names (CNN, BBC, etc.) - h3 tags within search result containers - Bio/profile links (Wikipedia, social media) usually appear at the top - To get actual search results, skip the first few elements if they're profiles IMPORTANT: Your output must be valid YAML that starts with a dash (-) for each step. INTERACTIVE WAIT USAGE: - When user asks to "wait for me to sign in" or "pause for manual action", use: - wait: {message: "Press Enter when you have signed in"} - This will pause the workflow and display the message in the terminal - The user can then perform manual actions in the browser - The workflow continues when the user presses Enter - Examples: - wait: {message: "Press Enter after completing 2FA"} - wait: {message: "Press Enter when you've accepted cookies"} - wait: {message: "Sign in to your account and press Enter to continue"} SELECTOR SYNTAX RULES: - ALWAYS use double quotes for attribute selectors: input[type="search"] (NOT input[type='search']) - NEVER use escaped quotes like input[type=\'search\'] or input[type=\"search\"] - For complex attribute values, keep using double quotes: a[href="https://example.com"] - Valid examples: - wait: input[type="search"] - click: button[data-testid="submit"] - type: selector: input[name="username"] text: my text - Invalid examples (DO NOT USE): - wait: input[type='search'] # Single quotes will fail - click: input[type=\'search\'] # Escaped quotes will fail - wait: "input[type='search']" # Wrapping the whole selector in quotes is wrong LEARNING FROM FAILURES: - When you see previous attempts with runtime output, analyze what went wrong - DO NOT append to or modify the previous YAML - start completely fresh - Use the runtime errors to understand what selectors/approaches don't work - Apply lessons learned to create a working solution from scratch CHECKPOINT USAGE: - Use checkpoint command to mark key decision points in workflows - Good places for checkpoints: - After successful login/authentication - Before major navigation to different sections - After completing a logical group of actions - Before actions that might have multiple paths - Example: - checkpoint: "logged-in" - checkpoint: {name: "search-results-loaded"} - This allows users to continue workflows from specific points later`; // Add current page context if available if (domQuickScan) { prompt += `\n\n${domQuickScan}\n\nIMPORTANT: Use the exact selectors shown above when interacting with these elements.`; } // Add context from previous attempts if (previousAttempts && previousAttempts.length > 0) { prompt += "\n\nPREVIOUS ATTEMPTS AND RESULTS:"; previousAttempts.forEach((attempt, idx) => { prompt += `\n\nšŸ•‘ Attempt ${idx + 1}`; // 1. The YAML Claude produced prompt += `\n--- YAML (attempt ${idx + 1}) ---\n${attempt.yaml}\n`; // 2. The verbatim run output (every step) if (attempt.fullOutput) { prompt += `\n--- Runtime output (attempt ${idx + 1}) ---\n${attempt.fullOutput}\n`; } // 3. Short structured analysis (kept for backward-compat) if (attempt.result) { prompt += workflow_executor_js_1.WorkflowExecutor.formatResultsForAnalysis(attempt.result, attempt.prompt, attempt.yaml); // Add detailed error analysis for learning const failedSteps = attempt.result.steps.filter(s => !s.success); if (failedSteps.length > 0) { prompt += "\n\nERROR PATTERNS TO LEARN FROM:"; for (const step of failedSteps) { if (step.error?.includes('not visible')) { prompt += `\n- Step ${step.stepNumber}: Element "${step.args}" exists but is not visible. This suggests it may need to be revealed by another interaction first.`; } else if (step.error?.includes('Timeout') && step.error?.includes('exceeded')) { prompt += `\n- Step ${step.stepNumber}: Selector "${step.args}" timed out. Either the selector is wrong or the element doesn't exist yet.`; } else if (step.error?.includes('multiple elements')) { prompt += `\n- Step ${step.stepNumber}: Selector "${step.args}" matched multiple elements. Use a more specific selector.`; } } } } if (attempt.claudeAnalysis) { prompt += `\nAnalysis: ${attempt.claudeAnalysis}`; } }); prompt += "\n\nBased on the previous attempts, generate an improved workflow that addresses the issues."; // Add specific guidance for data extraction failures const lastAttempt = previousAttempts[previousAttempts.length - 1]; if (lastAttempt?.result && this.hasEmptyDataExtraction(lastAttempt.result)) { prompt += `\n\nIMPORTANT: The previous attempt failed to extract any data. The selectors used did not match any elements on the page.`; if (domDigest) { prompt += `\n\nCURRENT PAGE STRUCTURE:\n${domDigest}\n\nUse the patterns shown above to create working selectors.`; } else { prompt += `\nBased on the DOM analysis, try using more generic selectors or the suggested patterns. Consider using broader selectors first to test, then narrow down.`; } } } return prompt; } cleanYamlOutput(raw) { // First, try to extract YAML if it's wrapped in backticks let cleaned = raw .replace(/```ya?ml/gi, "") .replace(/```/g, "") .trim(); // Find where the YAML actually starts (first line starting with -) const lines = cleaned.split('\n'); const yamlStartIndex = lines.findIndex(line => line.trim().startsWith('-')); if (yamlStartIndex > 0) { // Remove any text before the YAML cleaned = lines.slice(yamlStartIndex).join('\n'); } // Remove any text after the YAML ends const yamlLines = []; let inYaml = false; for (const line of cleaned.split('\n')) { if (line.trim().startsWith('-')) { inYaml = true; } if (inYaml) { // Stop if we hit a line that's not indented and doesn't start with - if (line.trim() && !line.startsWith(' ') && !line.trim().startsWith('-')) { break; } yamlLines.push(line); } } return yamlLines.join('\n').trim(); } validateYaml(yamlText) { try { const parsed = yaml.parse(yamlText); if (!Array.isArray(parsed)) { throw new Error("Workflow must be an array of steps"); } parsed.forEach((step, index) => { if (typeof step !== 'object' || step === null) { throw new Error(`Step ${index + 1} must be an object`); } const commands = Object.keys(step); if (commands.length === 0) { throw new Error(`Step ${index + 1} must contain a command`); } }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.error(`Invalid YAML generated: ${errorMessage}\n\nGenerated content:\n${yamlText}`); } } async executeWorkflow(yamlText, flags, instruction) { // Try to connect to Chrome, auto-launch if needed try { await this.connectToChrome(flags.port, flags.host, flags.launch, flags.profile, flags.headless, flags.verbose, flags.keepOpen); } catch (error) { // If connection failed and launch wasn't already true, try auto-launching if (!flags.launch) { this.log('šŸš€ No Chrome instance found. Auto-launching Chrome...'); await this.connectToChrome(flags.port, flags.host, true, // Force launch flags.profile, flags.headless, flags.verbose, flags.keepOpen); } else { throw error; } } if (!this.page) { this.error('Failed to connect to Chrome'); } // Initialize digest collector if needed if (!this.digestCollector) { this.digestCollector = new dom_digest_js_1.DOMDigestCollector(this.page); } // Parse workflow const workflow = yaml.parse(yamlText); // Execute with WorkflowExecutor const executor = new workflow_executor_js_1.WorkflowExecutor(this.page, instruction); const ora2 = (await Promise.resolve().then(() => tslib_1.__importStar(require('ora')))).default; let currentStepSpinner = null; let result; const executionStartTime = Date.now(); try { result = await executor.execute(workflow, { strict: flags["early-bailout"], // Use early bailout flag captureOutput: true, debug: flags.debug, onStepStart: (stepNumber, command, args) => { // Stop previous spinner if exists if (currentStepSpinner) { currentStepSpinner.stop(); } // Format args for display const argsDisplay = typeof args === 'string' ? args.substring(0, 50) + (args.length > 50 ? '...' : '') : args.selector || args.url || JSON.stringify(args).substring(0, 50); currentStepSpinner = ora2(`Step ${stepNumber}: ${command} ${argsDisplay}`).start(); }, onStepComplete: (stepNumber, command, success) => { if (currentStepSpinner) { if (success) { currentStepSpinner.succeed(`Step ${stepNumber}: ${command} āœ“`); } else { currentStepSpinner.fail(`Step ${stepNumber}: ${command} āœ—`); } currentStepSpinner = null; } } }); } catch (error) { // Clean up spinner on error if (currentStepSpinner) { currentStepSpinner.stop(); } // Extract step results from the executor even on failure const partialResults = executor.getStepResults(); const failedStep = partialResults[partialResults.length - 1]; // Create a result object for early bailout result = { success: false, totalSteps: workflow.length, successfulSteps: partialResults.filter(s => s.success).length, failedSteps: partialResults.filter(s => !s.success).length, steps: partialResults, totalDuration: Date.now() - executionStartTime, earlyBailout: true }; // Show early bailout message if (flags["early-bailout"] && failedStep) { this.log(`\nšŸ›‘ Execution stopped early due to failure at step ${failedStep.stepNumber}`); this.log(` Failed command: ${failedStep.command}`); if (failedStep.error) { this.log(` Error: ${failedStep.error}`); } } } // Clean up any remaining spinner if (currentStepSpinner) { currentStepSpinner.stop(); } // Create run log try { await this.runLogManager.init(); const currentUrl = this.page.url(); const digest = await this.digestCollector.collect(); const runLog = await this.runLogManager.createRunLog(result, { url: currentUrl, domDigest: digest }); // Store run log ID in the current attempt if (this.attempts.length > 0) { this.attempts[this.attempts.length - 1].runLogId = runLog.id; } } catch (error) { // Log creation failure shouldn't break the workflow if (flags.verbose) { this.warn(`Failed to create run log: ${error}`); } } return result; } showExecutionSummary(result) { this.log("\nšŸ“Š Execution Summary:"); this.log(` Total steps: ${result.totalSteps}`); this.log(` āœ… Successful: ${result.successfulSteps}`); this.log(` āŒ Failed: ${result.failedSteps}`); this.log(` ā±ļø Duration: ${result.totalDuration}ms`); if (result.earlyBailout) { this.log(` šŸ›‘ Early bailout: Yes`); } if (result.failedSteps > 0) { this.log("\nāŒ Failed steps:"); for (const step of result.steps.filter(step => !step.success)) { this.log(` Step ${step.stepNumber} (${step.command}): ${step.error}`); } } } async handleFeedbackLoop(originalInstruction, lastAttempt, flags, verification) { // Use provided verification or get Claude's analysis if (!verification) { const analysisPrompt = `${workflow_executor_js_1.WorkflowExecutor.formatResultsForAnalysis(lastAttempt.result ?? {}, originalInstruction, lastAttempt.yaml)}\n\nProvide a brief analysis of what went wrong and what should be changed.`; const ora3 = (await Promise.resolve().then(() => tslib_1.__importStar(require('ora')))).default; const analysisSpinner = ora3('šŸ” Analyzing results...').start(); const analysis = await (0, claude_js_1.askClaude)(analysisPrompt); analysisSpinner.succeed('šŸ” Analysis complete'); lastAttempt.claudeAnalysis = analysis; this.log("\nšŸ’” Claude's analysis:"); this.log(analysis); } else { lastAttempt.claudeAnalysis = verification.analysis; // If debug mode, add hint about arrow keys if (flags.debug && !flags.auto) { this.log('\n[DEBUG] If arrow keys are not working, check the terminal state logs above'); this.log('[DEBUG] Arrow keys should produce: Up=0x1b 0x5b 0x41, Down=0x1b 0x5b 0x42'); } } // Auto mode: automatically select AI's favorite suggestion if (flags.auto && verification?.favoriteSuggestion !== undefined && verification?.suggestions?.length) { // Prevent infinite loops - max 10 attempts if (this.attempts.length >= 10) { this.log(`\nāŒ Auto mode: Maximum attempts (10) reached without success.`); this.log(` Consider running without --auto flag for manual control.`); process.exit(1); } const favoriteIndex = verification.favoriteSuggestion; const favoriteSuggestion = verification.suggestions[favoriteIndex]; this.log(`\nšŸ¤– Auto mode: Attempt ${this.attempts.length + 1}/10 - Applying AI's favorite suggestion...`); this.log(` Selected: "${favoriteSuggestion}"`); const refinedInstruction = await this.createAIRefinedInstruction(originalInstruction, favoriteSuggestion, { lastResult: lastAttempt.result }); this.log(`šŸŽÆ Refined instruction: "${refinedInstruction}"`); // Add small delay for readability await new Promise(resolve => setTimeout(resolve, 1000)); // Continue with refined instruction await this.attemptWorkflow(refinedInstruction, flags, this.attempts); return; } // Show AI's recommendation if (verification?.suggestions?.length && verification.favoriteSuggestion !== undefined) { const recommendedSuggestion = verification.suggestions[verification.favoriteSuggestion]; this.log(`\nšŸ¤– AI recommends option ${verification.favoriteSuggestion + 1}:`); this.log(` ${recommendedSuggestion}`); } // Build menu choices with suggestions as first options const choices = []; // Add suggestion-based choices first if (verification?.suggestions?.length) { for (const [index, suggestion] of verification.suggestions.slice(0, 3).entries()) { const isFavorite = verification.favoriteSuggestion === index; const prefix = isFavorite ? '⭐' : 'šŸ’”'; choices.push({ name: `${prefix} ${index + 1}. ${suggestion}`, value: `suggestion_${index}` }); } } // Add standard choices choices.push({ name: 'šŸ”§ Autofix - Let Claude try again with improvements', value: 'autofix' }, { name: 'āœļø Modify prompt - Change what you\'re asking for', value: 'modify' }, { name: 'šŸ”„ Refine with feedback - Correct the results', value: 'refine-feedback' }, { name: 'šŸŽÆ Refine selectors - Help Claude find the right elements', value: 'refine' }, { name: 'šŸ’¾ Save anyway - Keep current workflow', value: 'save' }, { name: '🚪 Exit without saving', value: 'quit' }); // Debug logging for menu state if (flags.debug) { this.log('\n[DEBUG] About to show select menu'); this.log(`[DEBUG] Number of choices: ${choices.length}`); this.log(`[DEBUG] Choices:`); choices.forEach((c, i) => { this.log(` ${i}: ${c.name} -> ${c.value}`); }); } // Ensure terminal is in proper state for interactive prompt await this.stabilizeTerminal(flags.debug); let action; if (flags.debug) { this.log('\n[DEBUG] About to call select() prompt'); this.log(`[DEBUG] Terminal is ready for Inquirer prompt`); } const result = await this.safePrompt(() => (0, prompts_1.select)({ message: 'The workflow needs improvement. What would you like to do?', choices })); if (!result) return; // This shouldn't happen as safePrompt exits on cancel action = result; // Handle suggestion selections if (action.startsWith('suggestion_')) { const suggestionIndex = Number.parseInt(action.split('_')[1]); const suggestion = verification?.suggestions?.[suggestionIndex] ?? ''; this.log(`\nšŸ“ Applying suggestion: "${suggestion}"`); // Use AI to intelligently merge the suggestion const refinedInstruction = await this.createAIRefinedInstruction(originalInstruction, suggestion, { lastResult: lastAttempt.result }); this.log(`šŸŽÆ Refined instruction: "${refinedInstruction}"`); // Keep previous attempts for context await this.attemptWorkflow(refinedInstruction, flags, this.attempts); return; } switch (action) { case 'autofix': this.log(`\nšŸ”„ Attempt ${this.attempts.length + 1}...`); await this.attemptWorkflow(originalInstruction, flags, this.attempts); break; case 'modify': { await this.stabilizeTerminal(flags.debug); const newInstruction = await this.safePrompt(() => (0, prompts_1.input)({ message: 'Enter modified instruction:', default: originalInstruction })); if (!newInstruction) return; // Reset attempts for new instruction this.attempts = []; await this.attemptWorkflow(newInstruction, flags); break; } case 'refine-feedback': { this.log(`\nšŸ“œ Original instruction: "${originalInstruction}"`); this.log('\nšŸ“Š Previous result:'); // Show the output from the last attempt if (lastAttempt.result) { const resultSummary = this.formatResultForFeedback(lastAttempt.result); this.log(resultSummary); } await this.stabilizeTerminal(flags.debug); const feedbackText = await this.safePrompt(() => (0, prompts_1.input)({ message: 'What needs to be corrected or improved?', validate: (value) => value.trim().length > 0 || 'Please provide your feedback' })); if (!feedbackText) return; // Create a structured refinement that includes the output const refinedInstruction = await this.createRefinedInstruction(originalInstruction, lastAttempt.result, feedbackText); this.log('\nšŸŽÆ Refined instruction:'); this.log(refinedInstruction); // Keep previous attempts for context await this.attemptWorkflow(refinedInstruction, flags, this.attempts); break; } case 'refine': { await this.stabilizeTerminal(flags.debug); const refinementType = await this.safePrompt(() => (0, prompts_1.select)({ message: 'What would you like to refine?', choices: [ { name: 'Specify exact element text or attributes', value: 'element' }, { name: 'Add wait conditions', value: 'wait' }, { name: 'Specify data format', value: 'format' }, { name: 'Add error handling', value: 'error' } ] })); if (!refinementType) return; let refinedSelectorInstruction = originalInstruction; switch (refinementType) { case 'element': { await this.stabilizeTerminal(flags.debug); const elementDetails = await this.safePrompt(() => (0, prompts_1.input)({ message: 'Describe the element more specifically (e.g., "the blue submit button", "link containing \'Login\'"):' })); if (!elementDetails) return; refinedSelectorInstruction = await this.createAIRefinedInstruction(originalInstruction, `Look for ${elementDetails}`, { verificationType: 'element-specification' }); break; } case 'wait': { await this.stabilizeTerminal(flags.debug); const waitDetails = await this.safePrompt(() => (0, prompts_1.input)({ message: 'What should we wait for? (e.g., "wait for loading spinner to disappear", "wait 2 seconds"):' })); if (!waitDetails) return; refinedSelectorInstruction = await this.createAIRefinedInstruction(originalInstruction, waitDetails, { verificationType: 'wait-condition' }); break; } case 'format': { await this.stabilizeTerminal(flags.debug); const formatDetails = await this.safePrompt(() => (0, prompts_1.input)({ message: 'How should the data be formatted? (e.g., "as CSV", "only titles", "include links"):' })); if (!formatDetails) return; refinedSelectorInstruction = await this.createAIRefinedInstruction(originalInstruction, `Format the data ${formatDetails}`, { verificationType: 'data-format' }); break; } case 'error': refinedSelectorInstruction = await this.createAIRefinedInstruction(originalInstruction, 'If elements are not found, try alternative selectors. Handle any errors gracefully', { verificationType: 'error-handling' }); break; } this.log(`\nšŸŽÆ AI-refined instruction:`); this.log(refinedSelectorInstruction); await this.attemptWorkflow(refinedSelectorInstruction, flags, this.attempts); break; } case 'save': await this.promptSaveWorkflow(originalInstruction, lastAttempt.yaml, true, flags.debug); // Skip confirmation since user chose "Save anyway" break; case 'quit': this.log('šŸ‘‹ Exiting without saving.'); process.exit(0); break; } } async promptSaveWorkflow(instruction, yamlText, skipConfirmation = false, debug = false) { if (!skipConfirmation) { await this.stabilizeTerminal(debug); const shouldSave = await this.safePrompt(() => (0, prompts_1.confirm)({ message: 'Would you like to save this workflow?', default: true })); if (shouldSave === null) return; if (!shouldSave) { this.log('āœ… Workflow completed without saving.'); return; } } await this.stabilizeTerminal(debug); const name = await this.safePrompt(() => (0, prompts_1.input)({ message: 'Workflow name:', validate: (value) => value.length > 0 || 'Name is required' })); if (!name) return; await this.stabilizeTerminal(debug); const description = await this.safePrompt(() => (0, prompts_1.input)({ message: 'Description (optional):' })); if (description === null) return; await this.stabilizeTerminal(debug); const tags = await this.safePrompt(() => (0, prompts_1.input)({ message: 'Tags (comma-separated, optional):' })); if (tags === null) return; const tagArray = tags ? tags.split(',').map((t) => t.trim()).filter(Boolean) : undefined; try { const saved = await this.storage.save(name, instruction, yamlText, description, tagArray); this.log(`\nāœ… Workflow saved: ${saved.name} (${saved.id})`); this.log(`šŸ“ Location: ~/.chromancer/workflows/${saved.id}.json`); this.log(`\nšŸ’” Run it later with: chromancer workflows run "${name}"`); // Exit after successful save process.exit(0); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.error(`Failed to save workflow: ${errorMessage}`); } } async verifyResults(instruction, attempt, result) { // Check if this was a data extraction that returned empty results const isDataExtraction = instruction.toLowerCase().includes('scrape') || instruction.toLowerCase().includes('extract') || instruction.toLowerCase().includes('grab') || instruction.toLowerCase().includes('get'); const hasEmptyData = result.steps.some(step => step.command === 'evaluate' && step.output?.includes('0 items') || step.output?.includes('[]')); // If data extraction failed, do DOM inspection let domAnalysis = ''; if (isDataExtraction && hasEmptyData && this.page) { const ora4 = (await Promise.resolve().then(() => tslib_1.__importStar(require('ora')))).default; const inspectSpinner = ora4('šŸ” Inspecting page structure to find better selectors...').start(); const inspector = new dom_inspector_js_1.DOMInspector(this.page); const inspection = await inspector.inspectWithDigest(instruction); inspectSpinner.succeed('šŸ” Page inspection complete'); // Format digest for Claude if available let digestInfo = ''; if (inspection.digest) { digestInfo = ` URL: ${inspection.digest.url} Title: ${inspection.digest.title} Top Patterns (by frequency): ${inspection.digest.patterns.slice(0, 10).map(p => ` ${p.selector} (${p.count} elements)`).join('\n')} Sample Text Content: ${inspection.digest.texts.slice(0, 10).map(t => ` "${t}"`).join('\n')} `; } domAnalysis = ` DOM INSPECTION RESULTS: - Found ${inspection.selectors.common.length} repeated element patterns - Most common pattern: ${inspection.selectors.common[0] || 'none found'} - Page has ${inspection.structure.headings.length} headings, ${inspection.structure.links.length} links ${digestInfo} ${inspection.visibility?.hiddenElements?.length > 0 ? ` VISIBILITY ANALYSIS: Hidden elements detected: ${inspection.visibility.hiddenElements.map(h => `- ${h.selector}: ${h.reason}`).join('\n')} Potential reveal triggers: ${inspection.visibility.revealTriggers.map(t => `- Click "${t.triggerSelector}" to ${t.action}`).join('\n')} ` : ''} Suggestions: ${inspection.suggestions.join('\n')} Working selectors found: ${inspection.selectors.common.slice(0, 5).join(', ')} `; } // Build verification prompt const verificationPrompt = ` You are verifying if a browser automation workflow successfully achieved the user's goal. User's request: "${instruction}" Workflow executed with these results: ${workflow_executor_js_1.WorkflowExecutor.formatResultsForAnalysis(result, instruction, attempt.yaml)} ${domAnalysis ? `\n${domAnalysis}\n` : ''} VERIFICATION TASK: 1. Analyze if the workflow achieved what the user requested 2. CRITICALLY IMPORTANT: Check if extracted data actually matches what the user asked for - If user asked for "lesson titles", verify the extracted items are actually lesson titles - If user asked for "Cursor" content, verify the results relate to Cursor/cursor topics - Generic terms like "React", "TypeScript" etc. are NOT lesson titles - they're categ