chromancer
Version:
A powerful command-line interface for automating Chrome browser using Playwright. Perfect for web scraping, automation, testing, and browser workflows.
1,008 lines (980 loc) ⢠52.6 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 inquirer_1 = tslib_1.__importDefault(require("inquirer"));
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,
}),
};
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();
}
async run() {
const { args, flags } = await this.parse(AI);
// Initial attempt
await this.attemptWorkflow(args.instruction, flags);
}
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 verification = await this.verifyResults(instruction, attempt, result);
// Show verification to user
this.log("\nš AI Verification:");
this.log(verification.analysis);
// Handle next steps based on verification
if (verification.success) {
this.log(`\nā
${verification.reason}`);
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})
- 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")
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.
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`;
// 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;
const result = await executor.execute(workflow, {
strict: false, // Don't stop on errors, we want to see all results
captureOutput: true,
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;
}
}
});
// 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.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;
// Show suggestions if available
if (verification.suggestions && verification.suggestions.length > 0) {
this.log("\nš” Suggestions for improvement:");
for (const [index, suggestion] of verification.suggestions.entries()) {
this.log(` ${index + 1}. ${suggestion}`);
}
}
}
// 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()) {
choices.push({
name: `š” ${index + 1}. ${suggestion}`,
value: `suggestion_${index}`
});
}
choices.push({ name: 'āāāāāāāāāāāāāāāāā', value: 'separator', disabled: true });
}
// 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' });
const { action } = await inquirer_1.default.prompt([{
type: 'list',
name: 'action',
message: 'The workflow needs improvement. What would you like to do?',
choices
}]);
// Handle suggestion selections
if (action.startsWith('suggestion_')) {
const suggestionIndex = Number.parseInt(action.split('_')[1]);
const suggestion = verification?.suggestions?.[suggestionIndex] ?? '';
const appendedInstruction = `${originalInstruction}. ${suggestion}`;
this.log(`\nš Applying suggestion: "${suggestion}"`);
this.log(`Updated instruction: "${appendedInstruction}"`);
// Keep previous attempts for context
await this.attemptWorkflow(appendedInstruction, 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': {
const { newInstruction } = await inquirer_1.default.prompt([{
type: 'input',
name: 'newInstruction',
message: 'Enter modified instruction:',
default: originalInstruction
}]);
// 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);
}
const { feedbackText } = await inquirer_1.default.prompt([{
type: 'input',
name: 'feedbackText',
message: 'What needs to be corrected or improved?',
validate: (input) => input.trim().length > 0 || 'Please provide your feedback'
}]);
// Create a structured refinement that includes the output
const refinedInstruction = this.createRefinedInstruction(originalInstruction, lastAttempt.result, feedbackText);
this.log('\nš Refining with your feedback...');
// Keep previous attempts for context
await this.attemptWorkflow(refinedInstruction, flags, this.attempts);
break;
}
case 'refine': {
const { refinementType } = await inquirer_1.default.prompt([{
type: 'list',
name: 'refinementType',
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' }
]
}]);
let refinedSelectorInstruction = originalInstruction;
switch (refinementType) {
case 'element': {
const { elementDetails } = await inquirer_1.default.prompt([{
type: 'input',
name: 'elementDetails',
message: 'Describe the element more specifically (e.g., "the blue submit button", "link containing \'Login\'"):'
}]);
refinedSelectorInstruction = `${originalInstruction}. Look for ${elementDetails}`;
break;
}
case 'wait': {
const { waitDetails } = await inquirer_1.default.prompt([{
type: 'input',
name: 'waitDetails',
message: 'What should we wait for? (e.g., "wait for loading spinner to disappear", "wait 2 seconds"):'
}]);
refinedSelectorInstruction = `${originalInstruction}. ${waitDetails}`;
break;
}
case 'format': {
const { formatDetails } = await inquirer_1.default.prompt([{
type: 'input',
name: 'formatDetails',
message: 'How should the data be formatted? (e.g., "as CSV", "only titles", "include links"):'
}]);
refinedSelectorInstruction = `${originalInstruction} and format ${formatDetails}`;
break;
}
case 'error':
refinedSelectorInstruction = `${originalInstruction}. If elements are not found, try alternative selectors. Handle any errors gracefully`;
break;
}
this.log(`\nš Refined instruction: "${refinedSelectorInstruction}"`);
await this.attemptWorkflow(refinedSelectorInstruction, flags, this.attempts);
break;
}
case 'save':
await this.promptSaveWorkflow(originalInstruction, lastAttempt.yaml);
break;
case 'quit':
this.log('š Exiting without saving.');
break;
}
}
async promptSaveWorkflow(instruction, yamlText) {
const { shouldSave } = await inquirer_1.default.prompt([{
type: 'confirm',
name: 'shouldSave',
message: 'Would you like to save this workflow?',
default: true
}]);
if (!shouldSave) {
this.log('ā
Workflow completed without saving.');
return;
}
const { name, description, tags } = await inquirer_1.default.prompt([
{
type: 'input',
name: 'name',
message: 'Workflow name:',
validate: (input) => input.length > 0 || 'Name is required'
},
{
type: 'input',
name: 'description',
message: 'Description (optional):'
},
{
type: 'input',
name: 'tags',
message: 'Tags (comma-separated, optional):'
}
]);
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}"`);
}
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. Check if any extracted data looks correct and complete
3. Identify any potential issues or missing steps
Please provide:
1. A brief analysis (2-3 sentences) of the results
2. Whether it was successful (true/false)
3. A reason why it succeeded or failed
4. If failed, EXACTLY 3 specific, actionable suggestions that can be directly appended to the original instruction
Format your response as JSON:
{
"success": boolean,
"analysis": "Your 2-3 sentence analysis",
"reason": "Brief reason for success/failure",
"suggestions": ["actionable suggestion 1", "actionable suggestion 2", "actionable suggestion 3"] // REQUIRED if failed, must be 3 items
}
IMPORTANT for suggestions:
- Each suggestion must be a complete instruction that can be appended to the original command
- Make them specific and actionable (e.g., "Press Enter key after typing instead of clicking button")
- Do NOT use vague language like "try different selector" - be specific about what to try
`;
try {
const response = await (0, claude_js_1.askClaude)(verificationPrompt);
// Try to parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? false,
analysis: parsed.analysis || "Unable to analyze results",
reason: parsed.reason || "No reason provided",
suggestions: parsed.suggestions
};
}
// Fallback if not proper JSON
return {
success: response.toLowerCase().includes("success") || response.toLowerCase().includes("correct"),
analysis: response,
reason: "Unable to parse structured response"
};
}
catch (error) {
// If verification fails, assume workflow succeeded to avoid blocking
return {
success: true,
analysis: "Verification step encountered an error",
reason: "Proceeding with workflow as successful"
};
}
}
async handleSuccessfulWorkflow(instruction, yamlText, flags) {
const { action } = await inquirer_1.default.prompt([{
type: 'list',
name: 'action',
message: 'The workflow succeeded! What would you like to do?',
choices: [
{ name: 'š¾ Save workflow for future use', value: 'save' },
{ name: 'š Run again', value: 'run' },
{ name: 'āļø Modify and try different approach', value: 'modify' },
{ name: 'š Refine with feedback - Different results needed', value: 'refine-feedback' },
{ name: 'š Continue building workflow from here...', value: 'continue' },
{ name: 'ā
Done', value: 'done' }
]
}]);
switch (action) {
case 'save':
await this.promptSaveWorkflow(instruction, yamlText);
break;
case 'run':
this.log("\nš Running workflow again...");
await this.attemptWorkflow(instruction, flags, this.attempts);
break;
case 'modify': {
const { newInstruction } = await inquirer_1.default.prompt([{
type: 'input',
name: 'newInstruction',
message: 'Enter modified instruction:',
default: instruction
}]);
this.attempts = [];
await this.attemptWorkflow(newInstruction, flags);
break;
}
case 'refine-feedback': {
this.log(`\nš Original instruction: "${instruction}"`);
this.log('\nš Current result:');
// Show the output from the last successful attempt
const lastSuccessfulAttempt = this.attempts[this.attempts.length - 1];
if (lastSuccessfulAttempt?.result) {
const resultSummary = this.formatResultForFeedback(lastSuccessfulAttempt.result);
this.log(resultSummary);
}
const { feedbackText } = await inquirer_1.default.prompt([{
type: 'input',
name: 'feedbackText',
message: 'What needs to be different about the results?',
validate: (input) => input.trim().length > 0 || 'Please provide your feedback'
}]);
// Create a structured refinement that includes the output
const refinedWithFeedback = this.createRefinedInstruction(instruction, lastSuccessfulAttempt?.result, feedbackText);
this.log('\nš Refining with your feedback...');
// Keep previous attempts for context
await this.attemptWorkflow(refinedWithFeedback, flags, this.attempts);
break;
}
case 'continue': {
this.log('\nš Continue building from current page...');
if (!this.page) {
this.error('No active page connection');
}
const currentUrl = this.page.url();
this.log(`š Current page: ${currentUrl}`);
const { continuationInstruction } = await inquirer_1.default.prompt([{
type: 'input',
name: 'continuationInstruction',
message: 'What would you like to do next from this page?',
validate: (input) => input.trim().length > 0 || 'Please describe what to do next'
}]);
// Continue with existing workflow as base
await this.continueWorkflow(instruction, yamlText, continuationInstruction, flags);
break;
}
case 'done':
this.log('ā
Great! Workflow completed successfully.');
break;
}
}
hasEmptyDataExtraction(result) {
return result.steps.some(step => step.command === 'evaluate' &&
(step.output?.includes('0 items') || step.output?.includes('[]')));
}
async continueWorkflow(originalInstruction, existingYaml, continuationInstruction, flags) {
// Build a special prompt for continuing workflows
if (!this.page) {
this.error('No active page connection');
}
const currentUrl = this.page.url();
const pageTitle = await this.page?.title() ?? '';
const continuationPrompt = `You are continuing an existing workflow. The user has navigated to an interesting page and wants to extend the workflow from there.
EXISTING WORKFLOW:
Original instruction: "${originalInstruction}"
Current YAML:
${existingYaml}
CURRENT STATE:
- URL: ${currentUrl}
- Page Title: ${pageTitle}
- The workflow above has already been executed successfully
- The browser is now on the page where the user wants to continue
CONTINUATION REQUEST:
"${continuationInstruction}"
IMPORTANT RULES:
1. Generate ONLY the NEW steps to add to the workflow
2. Do NOT repeat the existing steps
3. Start your YAML output with the first new step
4. The new steps should seamlessly continue from where the existing workflow left off
5. Return ONLY valid YAML - no explanations
${this.buildSystemPrompt().split('AVAILABLE COMMANDS:')[1]}`;
this.log("\nš¤ Generating continuation steps...");
try {
const raw = await (0, claude_js_1.askClaude)(continuationPrompt);
const newStepsYaml = this.cleanYamlOutput(raw);
// Validate the new steps
this.validateYaml(newStepsYaml);
// Combine existing and new YAML
const combinedYaml = this.combineYamlWorkflows(existingYaml, newStepsYaml);
this.log("\nš Extended workflow:");
this.log(combinedYaml);
// Execute only the new steps
const newSteps = yaml.parse(newStepsYaml);
if (!this.page) {
this.error('No active page connection');
}
const executor = new workflow_executor_js_1.WorkflowExecutor(this.page, continuationInstruction);
this.log("\nš Executing new steps...");
const result = await executor.execute(newSteps, {
strict: false,
captureOutput: true
});
this.showExecutionSummary(result);
// Create a new attempt with the combined workflow
const attempt = {
prompt: `${originalInstruction} + ${continuationInstruction}`,
yaml: combinedYaml,
result
};
this.attempts.push(attempt);
// Verify and handle next steps
if (flags.interactive) {
const verification = await this.verifyResults(`${originalInstruction} and then ${continuationInstruction}`, attempt, result);
this.log("\nš AI Verification:");
this.log(verification.analysis);
if (verification.success) {
this.log(`\nā
${verification.reason}`);
await this.handleSuccessfulWorkflow(`${originalInstruction} and then ${continuationInstruction}`, combinedYaml, flags);
}
else {
this.log(`\nā ļø