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
JavaScript
"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