Compare commits

..

5 Commits

Author SHA1 Message Date
jackwener 025583f666 feat: AutoResearch framework — Karpathy-style autonomous iteration loop
Add an AutoResearch framework for automated optimization of OpenCLI Operate,
based on Karpathy's autoresearch methodology (constraint + mechanical metric
+ unbounded loop).

Core components:
- engine.ts: 8-phase loop (review → ideate → modify → commit → verify →
  guard → decide → log → repeat) with safe_revert and stuck detection
- config.ts: typed config + CLI arg parser + metric extraction
- logger.ts: TSV append-only results log with metadata header

Commands:
- commands/run.ts: main autonomous loop, spawns Claude Code per iteration
- commands/plan.ts: interactive config wizard with verify dry-run
- commands/fix.ts: auto-detect broken state, iteratively fix errors
- commands/debug.ts: hypothesis-driven debugging for specific failing tasks

Presets:
- operate-reliability: optimize browse-task pass rate (Layer 1)
- skill-quality: optimize skill E2E pass rate (Layer 2)
2026-04-03 02:58:10 +08:00
jackwener b65dbbf6ea fix: address code review — injection, silent failure, setter prototype
1. clickWithQuads: escape ref with JSON.stringify before inserting into
   JS strings and CSS selectors (injection risk)
2. base-page click: throw error when both JS click and CDP fallback fail
   instead of silently succeeding
3. typeTextJs: use matching prototype for native setter
   (HTMLTextAreaElement for textarea, HTMLInputElement for input)
2026-04-03 02:37:06 +08:00
jackwener 6dfc90f9c6 feat: Browser Use best practices — click/type/state improvements
Inspired by deep analysis of Browser Use's design patterns:

1. Framework listener detection (React/Vue/Angular)
   - Detect __reactProps$ onClick, Vue _vei, Angular ng-reflect-click
   - Catches <div onClick> elements that pure ARIA/tag heuristics miss

2. Click CDP fallback
   - clickJs() now returns coordinates on failure
   - BasePage.click() falls back to CDP Input.dispatchMouseEvent
   - Page.clickWithQuads() uses DOM.getContentQuads for inline elements

3. Type improvements
   - React-compatible: use native HTMLInputElement.prototype.value setter
   - Contenteditable: selectAll + execCommand('insertText') for rich editors
   - Autocomplete: detect role=combobox, wait 400ms for dropdown suggestions

4. getContentQuads precise click
   - Page.clickWithQuads() for multi-line inline elements (e.g. wrapped <a>)
   - Falls back through getContentQuads → getBoxModel → JS click
2026-04-03 02:37:06 +08:00
jackwener b7be39d134 docs: fix operate skill — eval read-only, IIFE, interaction rules
- Add rule: NEVER use eval to click/type — use click/type/select commands
  (eval bypasses scrollIntoView + CDP pipeline, fails on off-screen elements)
- Add rule: eval is read-only, always wrap in IIFE to avoid variable conflicts
- Reorder Critical Rules for priority
- Add IIFE example in Extract section

Root cause: Claude Code was using eval("el.click()") instead of
click <index>, and hitting "already declared" errors from repeated
eval calls in the same page context.
2026-04-03 02:37:06 +08:00
jackwener bbe7495bca docs: improve operate skill with Browser Use best practices
- Add Critical Rules section (state over screenshot, verify with get value)
- Add Command Cost Guide (free/instant vs expensive vision tokens)
- Add Action Chaining Rules (safe to chain vs page-changing)
- Add Tips section
- Fix Core Workflow to use state/get value for verification, not screenshot
- Mark screenshot as "ONLY for user deliverables"

Inspired by Browser Use's design: DOM-first state representation,
action cost awareness, and multi-action chaining patterns.
2026-04-03 02:37:06 +08:00
32 changed files with 1391 additions and 61 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch:debug — Hypothesis-driven debugging for specific failing tasks.
*
* Scientific method: Gather → Hypothesize → Test → Classify → Log → Repeat
*
* Usage:
* npx tsx autoresearch/commands/debug.ts --task extract-npm-description
* npx tsx autoresearch/commands/debug.ts --task bench-imdb-matrix --iterations 5
*/
import { execSync } from 'node:child_process';
import { readFileSync, appendFileSync, writeFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from '../config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const TASKS_FILE = join(__dirname, '..', 'browse-tasks.json');
const DEBUG_LOG = join(ROOT, 'debug-results.tsv');
interface BrowseTask {
name: string;
steps: string[];
judge: { type: string; value?: string; minLength?: number; pattern?: string };
}
function exec(cmd: string): string {
try {
return execSync(cmd, {
cwd: ROOT, timeout: 30_000, encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (err: any) {
return err.stdout?.trim() ?? err.message ?? '';
}
}
function initLog(): void {
if (!existsSync(DEBUG_LOG)) {
writeFileSync(DEBUG_LOG, '# AutoResearch Debug Log\niteration\ttask\thypothesis\tresult\tverdict\tdescription\n', 'utf-8');
}
}
function appendLog(iteration: number, task: string, hypothesis: string, result: string, verdict: string, description: string): void {
appendFileSync(DEBUG_LOG, `${iteration}\t${task}\t${hypothesis}\t${result}\t${verdict}\t${description}\n`, 'utf-8');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const taskName = args.task;
const maxIterations = args.iterations ?? 10;
if (!taskName) {
console.error('Usage: npx tsx autoresearch/commands/debug.ts --task <task-name> [--iterations N]');
console.error('\nAvailable tasks:');
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
// Show only failing tasks
for (const task of tasks) {
try { exec('opencli operate close'); } catch {}
let lastOutput = '';
for (const step of task.steps) lastOutput = exec(step);
const passed = lastOutput.trim().length > 0; // simplified check
if (!passed) console.error(`${task.name}`);
}
process.exit(1);
}
const tasks: BrowseTask[] = JSON.parse(readFileSync(TASKS_FILE, 'utf-8'));
const task = tasks.find(t => t.name === taskName);
if (!task) {
console.error(`Task not found: ${taskName}`);
process.exit(1);
}
console.log(`\n🔍 AutoResearch Debug: ${taskName}`);
console.log(` Steps: ${task.steps.length}`);
console.log(` Judge: ${task.judge.type}${task.judge.value ? ` "${task.judge.value}"` : ''}`);
console.log(` Max iterations: ${maxIterations}\n`);
initLog();
// Phase 1: Gather — run the task and capture output
console.log('Phase 1: Gathering symptoms...');
try { exec('opencli operate close'); } catch {}
let lastOutput = '';
for (let i = 0; i < task.steps.length; i++) {
const step = task.steps[i];
console.log(` Step ${i + 1}: ${step.slice(0, 80)}`);
lastOutput = exec(step);
if (i < task.steps.length - 1) {
console.log(`${lastOutput.slice(0, 100)}`);
}
}
console.log(`\n Final output: ${lastOutput.slice(0, 200)}`);
console.log(` Judge expects: ${JSON.stringify(task.judge)}`);
// Phase 2: Hypothesize + investigate via Claude Code
for (let iter = 1; iter <= maxIterations; iter++) {
console.log(`\n━━━ Debug Iteration ${iter}/${maxIterations} ━━━`);
const prompt = `You are debugging a failing browser automation task.
## Task: ${taskName}
Steps:
${task.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}
## Judge criteria
${JSON.stringify(task.judge)}
## Last output
${lastOutput.slice(0, 500)}
## Instructions
1. Form a SPECIFIC, FALSIFIABLE hypothesis about why this task fails
2. Run the MINIMUM experiment to test your hypothesis (e.g. run one step, check output)
3. Classify: CONFIRMED (bug found), DISPROVEN (try different hypothesis), INCONCLUSIVE
4. If CONFIRMED: describe the root cause and suggest a fix
5. Output format: one line "HYPOTHESIS: ...", one line "RESULT: CONFIRMED|DISPROVEN|INCONCLUSIVE — ..."
Do NOT fix the code — just diagnose. Use opencli operate commands to investigate.`;
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(opencli:*),Bash(npm:*),Read,Grep,Glob" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{ cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
// Extract hypothesis and result
const hypMatch = result.match(/HYPOTHESIS:\s*(.+)/i);
const resMatch = result.match(/RESULT:\s*(CONFIRMED|DISPROVEN|INCONCLUSIVE)\s*[-—]\s*(.+)/i);
const hypothesis = hypMatch?.[1]?.trim() ?? 'unknown';
const verdict = resMatch?.[1]?.trim() ?? 'INCONCLUSIVE';
const description = resMatch?.[2]?.trim() ?? result.split('\n').pop()?.trim() ?? '';
console.log(` Hypothesis: ${hypothesis.slice(0, 100)}`);
console.log(` Verdict: ${verdict}${description.slice(0, 100)}`);
appendLog(iter, taskName, hypothesis, lastOutput.slice(0, 50), verdict, description);
if (verdict === 'CONFIRMED') {
console.log(`\n✅ Root cause found at iteration ${iter}!`);
console.log(` ${description}`);
break;
}
} catch (err: any) {
console.error(` Error: ${err.message?.slice(0, 100)}`);
appendLog(iter, taskName, 'error', '', 'CRASH', err.message?.slice(0, 80) ?? '');
}
// Re-run task for fresh output
try { exec('opencli operate close'); } catch {}
for (const step of task.steps) lastOutput = exec(step);
}
try { exec('opencli operate close'); } catch {}
console.log(`\nDebug log saved to: ${DEBUG_LOG}\n`);
}
main();
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch:fix — Iterative error elimination.
*
* Auto-detects broken state (build → test → browse tests) and iteratively
* fixes errors one at a time. Stops when error count reaches 0.
*
* Priority: build errors → test failures → browse task failures
*
* Usage:
* npx tsx autoresearch/commands/fix.ts
* npx tsx autoresearch/commands/fix.ts --iterations 10
*/
import { execSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from '../config.js';
import { Engine, type ModifyContext } from '../engine.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
function exec(cmd: string): { ok: boolean; output: string } {
try {
const output = execSync(cmd, {
cwd: ROOT, timeout: 120_000, encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
return { ok: true, output };
} catch (err: any) {
return { ok: false, output: (err.stdout ?? '') + '\n' + (err.stderr ?? '') };
}
}
/** Detect current broken state and return verify command + error count */
function detectBrokenState(): { verify: string; errors: number; description: string } | null {
// 1. Build
const build = exec('npm run build 2>&1');
if (!build.ok) {
const errorCount = (build.output.match(/error TS/g) || []).length || 1;
return {
verify: 'npm run build 2>&1 | grep -c "error TS" || echo 0',
errors: errorCount,
description: `${errorCount} TypeScript build error(s)`,
};
}
// 2. Tests
const test = exec('npm test 2>&1');
if (!test.ok) {
const failMatch = test.output.match(/(\d+)\s+fail/i);
const errorCount = failMatch ? parseInt(failMatch[1], 10) : 1;
return {
verify: 'npm test 2>&1 | grep -oP "\\d+(?= fail)" || echo 0',
errors: errorCount,
description: `${errorCount} test failure(s)`,
};
}
// 3. Browse tests
const browse = exec('npx tsx autoresearch/eval-browse.ts 2>&1');
const scoreMatch = browse.output.match(/SCORE=(\d+)\/(\d+)/);
if (scoreMatch) {
const passed = parseInt(scoreMatch[1], 10);
const total = parseInt(scoreMatch[2], 10);
const failures = total - passed;
if (failures > 0) {
return {
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
errors: failures,
description: `${failures} browse task failure(s) (${passed}/${total})`,
};
}
}
return null; // all clean
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const maxIterations = args.iterations ?? 20;
console.log('\n🔧 AutoResearch Fix — Detecting broken state...\n');
const broken = detectBrokenState();
if (!broken) {
console.log(' ✓ All clean — nothing to fix!\n');
return;
}
console.log(` Found: ${broken.description}`);
console.log(` Verify: ${broken.verify}\n`);
const config = {
goal: `Fix all errors: ${broken.description}`,
scope: ['src/**/*.ts', 'extension/src/**/*.ts'],
metric: 'error_count',
direction: 'lower' as const,
verify: broken.verify,
guard: 'npm run build',
iterations: maxIterations,
minDelta: 1,
};
const logPath = join(ROOT, 'autoresearch-results.tsv');
const engine = new Engine(config, logPath, {
modify: async (ctx: ModifyContext) => {
const prompt = `Fix ONE error. Current error count: ${ctx.currentMetric}. Goal: 0 errors.
Read the error output, understand the root cause, and make ONE focused fix.
Do NOT fix multiple unrelated errors at once.
Do NOT modify test files.
${ctx.stuckHint ? `STUCK HINT: ${ctx.stuckHint}` : ''}`;
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{ cwd: ROOT, timeout: 180_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
const lines = result.split('\n').filter(l => l.trim());
return lines[lines.length - 1]?.trim()?.slice(0, 120) || 'fix attempt';
} catch {
return null;
}
},
onStatus: (msg) => console.log(msg),
});
try {
const results = await engine.run();
const finalMetric = results[results.length - 1]?.metric ?? broken.errors;
if (finalMetric === 0) {
console.log('\n✅ All errors fixed!\n');
} else {
console.log(`\n⚠ ${finalMetric} error(s) remaining after ${maxIterations} iterations.\n`);
}
} catch (err: any) {
console.error(`\n❌ ${err.message}`);
process.exit(1);
}
}
main();
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch:plan — Interactive configuration wizard.
*
* Walks through goal, scope, metric, verify, guard settings
* and outputs a ready-to-paste run command.
*
* Usage:
* npx tsx autoresearch/commands/plan.ts
*/
import { execSync } from 'node:child_process';
import { createInterface } from 'node:readline';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PRESETS } from '../presets/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise(r => rl.question(q, r));
async function main() {
console.log('\n🔬 AutoResearch — Configuration Wizard\n');
// Offer presets first
const presetNames = Object.keys(PRESETS);
console.log('Available presets:');
presetNames.forEach((name, i) => {
console.log(` [${i + 1}] ${name}${PRESETS[name].goal}`);
});
console.log(` [0] Custom config\n`);
const choice = await ask('Choose preset or 0 for custom: ');
const idx = parseInt(choice, 10);
if (idx > 0 && idx <= presetNames.length) {
const name = presetNames[idx - 1];
const iterations = await ask('Iterations (empty = unbounded): ');
const iterFlag = iterations ? ` --iterations ${iterations}` : '';
console.log(`\n✅ Ready to run:\n`);
console.log(` npx tsx autoresearch/commands/run.ts --preset ${name}${iterFlag}\n`);
rl.close();
return;
}
// Custom config
const goal = await ask('Goal (what to improve): ');
const scope = await ask('Scope (file globs, comma-separated): ');
const metric = await ask('Metric name (e.g. pass_count, coverage): ');
const direction = await ask('Direction (higher/lower): ') as 'higher' | 'lower';
const verify = await ask('Verify command (must output a number): ');
// Dry-run verify
console.log('\n Dry-running verify command...');
try {
const output = execSync(verify, { cwd: ROOT, timeout: 120_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
const { extractMetric } = await import('../config.js');
const value = extractMetric(output);
if (value != null) {
console.log(` ✓ Verify works — current ${metric}: ${value}`);
} else {
console.log(` ⚠ Verify ran but no number extracted from output:\n ${output.slice(0, 200)}`);
}
} catch (err: any) {
console.log(` ✗ Verify failed: ${err.message?.slice(0, 100)}`);
}
const guard = await ask('Guard command (optional, press Enter to skip): ');
const iterations = await ask('Iterations (empty = unbounded): ');
const parts = ['npx tsx autoresearch/commands/run.ts'];
parts.push(`--goal "${goal}"`);
parts.push(`--scope "${scope}"`);
parts.push(`--metric "${metric}"`);
parts.push(`--direction ${direction}`);
parts.push(`--verify "${verify}"`);
if (guard) parts.push(`--guard "${guard}"`);
if (iterations) parts.push(`--iterations ${iterations}`);
console.log(`\n✅ Ready to run:\n`);
console.log(` ${parts.join(' \\\n ')}\n`);
rl.close();
}
main();
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env npx tsx
/**
* /autoresearch — Main autonomous iteration loop.
*
* Usage:
* npx tsx autoresearch/commands/run.ts --preset operate-reliability
* npx tsx autoresearch/commands/run.ts --preset operate-reliability --iterations 5
* npx tsx autoresearch/commands/run.ts --goal "..." --scope "src/*.ts" --verify "..." --iterations 10
*
* The modify callback spawns Claude Code to make ONE atomic change per iteration.
* Engine handles commit, verify, guard, keep/discard, and logging.
*/
import { execSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs, type AutoResearchConfig } from '../config.js';
import { Engine, type ModifyContext } from '../engine.js';
import { PRESETS } from '../presets/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
function buildModifyPrompt(ctx: ModifyContext, config: AutoResearchConfig): string {
const recent = ctx.recentLog.slice(-10).map(r =>
` ${r.status.padEnd(12)} ${r.description}`
).join('\n');
return `You are an autonomous improvement agent. Make ONE atomic change to improve this metric.
## Goal
${config.goal}
## Current State
- Metric (${config.metric}): ${ctx.currentMetric} (best: ${ctx.bestMetric})
- Iteration: ${ctx.iteration}
- Consecutive discards: ${ctx.consecutiveDiscards}
${ctx.stuckHint ? `\n## STUCK — Try a Different Approach\n${ctx.stuckHint}` : ''}
## Recent History
${recent || ' (no history yet)'}
## Git Log (recent experiments)
${ctx.gitLog.split('\n').slice(0, 10).join('\n')}
## Scope (files you can modify)
${ctx.scopeFiles.join('\n')}
## Rules
1. Make ONE atomic change (one logical intent, even if multiple files)
2. Read the failing test output or code BEFORE modifying
3. DO NOT modify test files or the verify command
4. Describe what you changed in one sentence (no "and" linking unrelated actions)
5. If previous approach was discarded, try something DIFFERENT
6. Focus on the specific failures — read error messages carefully`;
}
async function modify(ctx: ModifyContext, config: AutoResearchConfig): Promise<string | null> {
const prompt = buildModifyPrompt(ctx, config);
console.log(' Claude Code making a change...');
try {
const result = execSync(
`claude -p --dangerously-skip-permissions --allowedTools "Bash(npm:*),Bash(npx:*),Bash(git:*),Read,Edit,Write,Glob,Grep" --output-format text --no-session-persistence "${prompt.replace(/"/g, '\\"')}"`,
{
cwd: ROOT,
timeout: 180_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}
).trim();
// Extract description from Claude's response (last non-empty line or summary)
const lines = result.split('\n').filter(l => l.trim());
const desc = lines[lines.length - 1]?.trim() || 'change made by Claude Code';
return desc.slice(0, 120);
} catch (err: any) {
console.error(' Claude Code failed:', err.message?.slice(0, 100));
return null;
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
// Resolve config from preset or CLI args
let config: AutoResearchConfig;
if (args.preset) {
config = PRESETS[args.preset];
if (!config) {
console.error(`Unknown preset: ${args.preset}`);
console.error(`Available: ${Object.keys(PRESETS).join(', ')}`);
process.exit(1);
}
// Allow CLI overrides
if (args.iterations != null) config = { ...config, iterations: args.iterations };
if (args.guard != null) config = { ...config, guard: args.guard };
} else if (args.goal && args.verify) {
config = {
goal: args.goal,
scope: args.scope ?? ['src/**/*.ts'],
metric: args.metric ?? 'score',
direction: args.direction ?? 'higher',
verify: args.verify,
guard: args.guard,
iterations: args.iterations,
minDelta: args.minDelta,
};
} else {
console.error('Usage: npx tsx autoresearch/commands/run.ts --preset <name> [--iterations N]');
console.error(' or: npx tsx autoresearch/commands/run.ts --goal "..." --verify "..." --scope "..."');
console.error(`\nAvailable presets: ${Object.keys(PRESETS).join(', ')}`);
process.exit(1);
}
console.log(`\n🔬 AutoResearch: ${config.goal}`);
console.log(` Metric: ${config.metric} (${config.direction})`);
console.log(` Verify: ${config.verify}`);
console.log(` Guard: ${config.guard ?? '(none)'}`);
console.log(` Iterations: ${config.iterations ?? '∞'}`);
console.log('');
const logPath = join(ROOT, 'autoresearch-results.tsv');
const engine = new Engine(config, logPath, {
modify: (ctx) => modify(ctx, config),
onStatus: (msg) => console.log(msg),
});
try {
await engine.run();
} catch (err: any) {
console.error(`\n❌ ${err.message}`);
process.exit(1);
}
}
main();
+82
View File
@@ -0,0 +1,82 @@
/**
* AutoResearch Configuration — type definitions and CLI parsing.
*
* Based on Karpathy's autoresearch: constraint + mechanical metric + unbounded loop.
*/
export interface AutoResearchConfig {
/** Plain-language goal, e.g. "Increase operate pass rate to 59/59" */
goal: string;
/** Glob patterns for files the agent can modify */
scope: string[];
/** What the metric measures, e.g. "pass_count" */
metric: string;
/** Whether improvement means the number goes up or down */
direction: 'higher' | 'lower';
/** Shell command that outputs a number (the metric value) */
verify: string;
/** Optional guard command — must pass for a keep decision */
guard?: string;
/** Max iterations (undefined = unbounded) */
iterations?: number;
/** Minimum delta to count as real improvement (noise filter) */
minDelta?: number;
}
export type IterationStatus =
| 'baseline'
| 'keep'
| 'keep (reworked)'
| 'discard'
| 'crash'
| 'no-op'
| 'hook-blocked';
export interface IterationResult {
iteration: number;
commit: string;
metric: number;
delta: number;
guard: 'pass' | 'fail' | '-';
status: IterationStatus;
description: string;
}
/** Parse CLI args into a partial config (missing fields filled by preset or prompts) */
export function parseArgs(argv: string[]): Partial<AutoResearchConfig> & { preset?: string; task?: string } {
const config: Partial<AutoResearchConfig> & { preset?: string; task?: string } = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
switch (arg) {
case '--preset': config.preset = next; i++; break;
case '--goal': config.goal = next; i++; break;
case '--scope': config.scope = next?.split(','); i++; break;
case '--metric': config.metric = next; i++; break;
case '--direction': config.direction = next as 'higher' | 'lower'; i++; break;
case '--verify': config.verify = next; i++; break;
case '--guard': config.guard = next; i++; break;
case '--iterations': config.iterations = parseInt(next, 10); i++; break;
case '--min-delta': config.minDelta = parseFloat(next); i++; break;
case '--task': config.task = next; i++; break;
}
}
return config;
}
/** Extract a number from command output using common patterns */
export function extractMetric(output: string): number | null {
// Try: last line that looks like a number
const lines = output.trim().split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
// Match standalone numbers: "56", "95.2", "SCORE=56/59" → 56
const scoreMatch = line.match(/SCORE[=:]\s*(\d+)/i);
if (scoreMatch) return parseFloat(scoreMatch[1]);
const numMatch = line.match(/^[\d.]+$/);
if (numMatch) return parseFloat(numMatch[0]);
}
// Fallback: first number in output
const fallback = output.match(/(\d+(?:\.\d+)?)/);
return fallback ? parseFloat(fallback[1]) : null;
}
+359
View File
@@ -0,0 +1,359 @@
/**
* AutoResearch Engine — Karpathy's 8-phase autonomous iteration loop.
*
* Phase 0: Precondition checks (git clean, no locks)
* Phase 1: Review (read scope files + log + git history)
* Phase 2: Ideate (select next change based on history)
* Phase 3: Modify (one atomic change — delegated to caller)
* Phase 4: Commit (git add + commit with experiment prefix)
* Phase 5: Verify (run verify command, extract metric)
* Phase 5.5: Guard (optional regression check)
* Phase 6: Decide (keep/discard/crash + rollback)
* Phase 7: Log (append TSV)
* Phase 8: Repeat
*/
import { execSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { type AutoResearchConfig, type IterationResult, type IterationStatus, extractMetric } from './config.js';
import { Logger } from './logger.js';
export interface EngineCallbacks {
/** Called at Phase 2-3: review context, ideate, and make ONE change.
* Return a one-sentence description of what was changed, or null to skip. */
modify(context: ModifyContext): Promise<string | null>;
/** Called when engine needs to report status */
onStatus?(msg: string): void;
}
export interface ModifyContext {
iteration: number;
bestMetric: number;
currentMetric: number;
recentLog: IterationResult[];
gitLog: string;
scopeFiles: string[];
consecutiveDiscards: number;
stuckHint: string | null;
}
const ROOT = join(import.meta.dirname ?? process.cwd(), '..');
function exec(cmd: string, opts?: { timeout?: number; cwd?: string }): string {
try {
return execSync(cmd, {
cwd: opts?.cwd ?? ROOT,
timeout: opts?.timeout ?? 120_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}).trim();
} catch (err: any) {
return err.stdout?.trim() ?? err.message ?? '';
}
}
function execStrict(cmd: string, opts?: { timeout?: number }): string {
return execSync(cmd, {
cwd: ROOT,
timeout: opts?.timeout ?? 120_000,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
}).trim();
}
export class Engine {
private config: AutoResearchConfig;
private logger: Logger;
private callbacks: EngineCallbacks;
private bestMetric: number = 0;
private currentMetric: number = 0;
private iteration: number = 0;
constructor(config: AutoResearchConfig, logPath: string, callbacks: EngineCallbacks) {
this.config = config;
this.logger = new Logger(logPath);
this.callbacks = callbacks;
}
private log(msg: string): void {
this.callbacks.onStatus?.(msg);
}
/** Phase 0: Precondition checks */
private checkPreconditions(): void {
// Git repo exists
try { execStrict('git rev-parse --git-dir'); }
catch { throw new Error('Not a git repository'); }
// Clean working tree
const status = exec('git status --porcelain');
if (status) throw new Error(`Working tree not clean:\n${status}`);
// No stale locks
if (existsSync(join(ROOT, '.git', 'index.lock'))) {
throw new Error('Stale .git/index.lock found — remove it first');
}
// Not detached HEAD
try { execStrict('git symbolic-ref HEAD'); }
catch { throw new Error('Detached HEAD — checkout a branch first'); }
}
/** Phase 5: Run verify command and extract metric */
private runVerify(): number | null {
this.log(' verify...');
const output = exec(this.config.verify, { timeout: 300_000 });
return extractMetric(output);
}
/** Phase 5.5: Run guard command */
private runGuard(): boolean {
if (!this.config.guard) return true;
this.log(' guard...');
try {
execStrict(this.config.guard, { timeout: 300_000 });
return true;
} catch {
return false;
}
}
/** Phase 4: Commit changes */
private commit(description: string): string | null {
// Stage all changes in scope (but not untracked outside scope)
exec('git add -A');
const diff = exec('git diff --cached --quiet; echo $?');
if (diff === '0') return null; // no changes
try {
execStrict(`git commit -m "experiment(operate): ${description.replace(/"/g, '\\"')}"`);
return exec('git rev-parse --short HEAD');
} catch {
// Hook failure
exec('git reset HEAD');
return 'hook-blocked';
}
}
/** Phase 6: Rollback */
private safeRevert(): void {
try {
execStrict('git revert HEAD --no-edit');
} catch {
exec('git revert --abort');
exec('git reset --hard HEAD~1');
}
}
/** Get stuck hint when >5 consecutive discards */
private getStuckHint(discards: number): string | null {
if (discards < 5) return null;
const hints = [
'Re-read ALL scope files from scratch. Try a completely different approach.',
'Review entire results log — what worked before? Try combining successful changes.',
'Try the OPPOSITE of what has been failing.',
'Try a radical architectural change instead of incremental tweaks.',
'Simplify — remove complexity rather than adding it.',
];
return hints[Math.min(discards - 5, hints.length - 1)];
}
/** Run the main loop */
async run(): Promise<IterationResult[]> {
const results: IterationResult[] = [];
// Phase 0: Preconditions
this.log('Phase 0: Precondition checks...');
this.checkPreconditions();
// Initialize logger
this.logger.init(this.config);
// Baseline measurement
this.log('Measuring baseline...');
const baseline = this.runVerify();
if (baseline == null) throw new Error('Verify command returned no metric for baseline');
this.bestMetric = baseline;
this.currentMetric = baseline;
const baselineCommit = exec('git rev-parse --short HEAD');
const baselineResult: IterationResult = {
iteration: 0,
commit: baselineCommit,
metric: baseline,
delta: 0,
guard: this.config.guard ? (this.runGuard() ? 'pass' : 'fail') : '-',
status: 'baseline',
description: `initial state — ${this.config.metric} ${baseline}`,
};
this.logger.append(baselineResult);
results.push(baselineResult);
this.log(`Baseline: ${this.config.metric} = ${baseline}`);
// Main loop
const maxIter = this.config.iterations ?? Infinity;
for (this.iteration = 1; this.iteration <= maxIter; this.iteration++) {
this.log(`\n━━━ Iteration ${this.iteration}${maxIter < Infinity ? `/${maxIter}` : ''} ━━━`);
// Phase 1: Review
const gitLog = exec('git log --oneline -20');
const recentLog = this.logger.readLast(20);
const scopeFiles = this.config.scope;
const consecutiveDiscards = this.logger.consecutiveDiscards();
// Phase 2-3: Ideate + Modify (delegated to callback)
const context: ModifyContext = {
iteration: this.iteration,
bestMetric: this.bestMetric,
currentMetric: this.currentMetric,
recentLog,
gitLog,
scopeFiles,
consecutiveDiscards,
stuckHint: this.getStuckHint(consecutiveDiscards),
};
let description: string | null;
try {
description = await this.callbacks.modify(context);
} catch (err: any) {
this.log(` modify error: ${err.message}`);
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'crash',
description: `modify crashed: ${err.message?.slice(0, 80)}`,
};
this.logger.append(result);
results.push(result);
continue;
}
if (!description) {
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'no-op',
description: 'no changes made',
};
this.logger.append(result);
results.push(result);
continue;
}
// Phase 4: Commit
this.log(` commit: ${description}`);
const commitHash = this.commit(description);
if (!commitHash) {
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'no-op',
description: `no diff after: ${description}`,
};
this.logger.append(result);
results.push(result);
continue;
}
if (commitHash === 'hook-blocked') {
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'hook-blocked',
description: `hook rejected: ${description}`,
};
this.logger.append(result);
results.push(result);
continue;
}
// Phase 5: Verify
const metric = this.runVerify();
if (metric == null) {
this.log(' verify crashed — reverting');
this.safeRevert();
const result: IterationResult = {
iteration: this.iteration,
commit: '-',
metric: this.currentMetric,
delta: 0,
guard: '-',
status: 'crash',
description: `verify crashed: ${description}`,
};
this.logger.append(result);
results.push(result);
continue;
}
const improved = this.config.direction === 'higher'
? metric > this.bestMetric
: metric < this.bestMetric;
const delta = +(metric - this.bestMetric).toFixed(4);
const absDelta = Math.abs(delta);
const minDelta = this.config.minDelta ?? 0;
// Phase 5.5: Guard
let guardResult: 'pass' | 'fail' | '-' = '-';
if (this.config.guard && improved && absDelta >= minDelta) {
guardResult = this.runGuard() ? 'pass' : 'fail';
}
// Phase 6: Decide
let status: IterationStatus;
if (improved && absDelta >= minDelta && (guardResult !== 'fail')) {
status = 'keep';
this.bestMetric = metric;
this.currentMetric = metric;
this.log(` ✓ KEEP — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
} else if (improved && guardResult === 'fail') {
this.log(' guard failed — reverting');
this.safeRevert();
status = 'discard';
this.log(` ✗ DISCARD (guard) — ${description}`);
} else {
this.safeRevert();
status = 'discard';
const reason = absDelta < minDelta ? 'below min delta' : 'no improvement';
this.log(` ✗ DISCARD (${reason}) — ${this.config.metric}: ${metric} (${delta >= 0 ? '+' : ''}${delta})`);
}
const result: IterationResult = {
iteration: this.iteration,
commit: status === 'keep' ? commitHash : '-',
metric,
delta,
guard: guardResult,
status,
description,
};
this.logger.append(result);
results.push(result);
}
// Summary
const keeps = results.filter(r => r.status === 'keep' || r.status === 'keep (reworked)');
const discards = results.filter(r => r.status === 'discard');
this.log(`\n${'━'.repeat(50)}`);
this.log(`Done: ${this.iteration - 1} iterations, ${keeps.length} kept, ${discards.length} discarded`);
this.log(`Final ${this.config.metric}: ${this.bestMetric} (started at ${results[0]?.metric})`);
return results;
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* AutoResearch TSV Logger — append-only results log with metadata header.
*/
import { writeFileSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
import type { AutoResearchConfig, IterationResult } from './config.js';
const COLUMNS = ['iteration', 'commit', 'metric', 'delta', 'guard', 'status', 'description'];
export class Logger {
constructor(private path: string) {}
/** Create the TSV file with metadata header */
init(config: AutoResearchConfig): void {
const header = [
`# metric_direction: ${config.direction === 'higher' ? 'higher_is_better' : 'lower_is_better'}`,
`# goal: ${config.goal}`,
`# scope: ${config.scope.join(', ')}`,
`# verify: ${config.verify}`,
config.guard ? `# guard: ${config.guard}` : null,
COLUMNS.join('\t'),
].filter(Boolean).join('\n');
writeFileSync(this.path, header + '\n', 'utf-8');
}
/** Append one iteration result */
append(result: IterationResult): void {
const row = [
result.iteration,
result.commit,
result.metric,
result.delta >= 0 ? `+${result.delta}` : result.delta,
result.guard,
result.status,
result.description,
].join('\t');
appendFileSync(this.path, row + '\n', 'utf-8');
}
/** Read last N entries for pattern recognition */
readLast(n: number): IterationResult[] {
if (!existsSync(this.path)) return [];
const lines = readFileSync(this.path, 'utf-8').split('\n')
.filter(l => l && !l.startsWith('#') && !l.startsWith('iteration'));
return lines.slice(-n).map(line => {
const [iteration, commit, metric, delta, guard, status, ...desc] = line.split('\t');
return {
iteration: parseInt(iteration, 10),
commit,
metric: parseFloat(metric),
delta: parseFloat(delta),
guard: guard as 'pass' | 'fail' | '-',
status: status as IterationResult['status'],
description: desc.join('\t'),
};
});
}
/** Count consecutive discards from the end */
consecutiveDiscards(): number {
const entries = this.readLast(20);
let count = 0;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].status === 'discard') count++;
else break;
}
return count;
}
}
+11
View File
@@ -0,0 +1,11 @@
export { operateReliability } from './operate-reliability.js';
export { skillQuality } from './skill-quality.js';
import type { AutoResearchConfig } from '../config.js';
import { operateReliability } from './operate-reliability.js';
import { skillQuality } from './skill-quality.js';
export const PRESETS: Record<string, AutoResearchConfig> = {
'operate-reliability': operateReliability,
'skill-quality': skillQuality,
};
@@ -0,0 +1,24 @@
/**
* Preset: Operate Command Reliability
*
* Optimizes opencli operate commands against the Layer 1 deterministic test suite.
* Metric: number of passing browse-tasks (out of 59).
*/
import type { AutoResearchConfig } from '../config.js';
export const operateReliability: AutoResearchConfig = {
goal: 'Increase operate command pass rate to 59/59 (100%)',
scope: [
'src/browser/dom-snapshot.ts',
'src/browser/dom-helpers.ts',
'src/browser/base-page.ts',
'src/browser/page.ts',
'src/cli.ts',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-browse.ts 2>&1 | tail -1',
guard: 'npm run build',
minDelta: 1,
};
+20
View File
@@ -0,0 +1,20 @@
/**
* Preset: Skill E2E Quality
*
* Optimizes the opencli-operate SKILL.md against the Layer 2 LLM E2E test suite.
* Metric: number of passing skill-tasks (out of 35).
*/
import type { AutoResearchConfig } from '../config.js';
export const skillQuality: AutoResearchConfig = {
goal: 'Increase skill E2E pass rate to 35/35 (100%)',
scope: [
'skills/opencli-operate/SKILL.md',
],
metric: 'pass_count',
direction: 'higher',
verify: 'npx tsx autoresearch/eval-skill.ts 2>&1 | tail -1',
guard: 'npm run build',
iterations: 20,
};
+58 -21
View File
@@ -4,7 +4,7 @@ description: Make websites accessible for AI agents. Navigate, click, type, extr
allowed-tools: Bash(opencli:*), Read, Edit, Write
---
# OpenCLI — Make Websites Accessible for AI Agents
# OpenCLI Operate — Browser Automation for AI Agents
Control Chrome step-by-step via CLI. Reuses existing login sessions — no passwords needed.
@@ -16,25 +16,49 @@ opencli doctor # Verify extension + daemon connectivity
Requires: Chrome running + OpenCLI Browser Bridge extension installed.
## Quickstart for AI Agents (1 step)
## Critical Rules
Point your AI agent to this file. It contains everything needed to operate browsers.
1. **ALWAYS use `state` to inspect the page, NEVER use `screenshot`**`state` returns structured DOM with `[N]` element indices, is instant and costs zero tokens. `screenshot` requires vision processing and is slow. Only use `screenshot` when the user explicitly asks to save a visual.
2. **ALWAYS use `click`/`type`/`select` for interaction, NEVER use `eval` to click or type**`eval "el.click()"` bypasses scrollIntoView and CDP click pipeline, causing failures on off-screen elements. Use `state` to find the `[N]` index, then `click <N>`.
3. **Verify inputs with `get value`, not screenshots** — after `type`, run `get value <index>` to confirm.
4. **Run `state` after every page change** — after `open`, `click` (on links), `scroll`, always run `state` to see the new elements and their indices. Never guess indices.
5. **Chain safe commands with `&&`**`type 3 "a" && type 4 "b" && click 7` is one call instead of three. But always run `state` first to get correct indices before chaining.
6. **`eval` is read-only** — use `eval` ONLY for data extraction (`JSON.stringify(...)`), never for clicking, typing, or navigating. Always wrap in IIFE to avoid variable conflicts: `eval "(function(){ const x = ...; return JSON.stringify(x); })()"`.
7. **Prefer `network` to discover APIs** — most sites have JSON APIs. API-based adapters are more reliable than DOM scraping.
## Quickstart for Humans (3 steps)
## Command Cost Guide
| Cost | Commands | When to use |
|------|----------|-------------|
| **Free & instant** | `state`, `get *`, `eval`, `network`, `scroll`, `keys` | Default — use these |
| **Free but changes page** | `open`, `click`, `type`, `select`, `back` | Interaction — run `state` after |
| **Expensive (vision tokens)** | `screenshot` | ONLY when user needs a saved image |
## Action Chaining Rules
Commands can be chained with `&&`. The browser persists via daemon, so chaining is safe.
**Safe to chain** — these don't change the page structure:
```bash
npm install -g @jackwener/opencli # 1. Install
# Install extension from chrome://extensions # 2. Load extension
opencli operate open https://example.com # 3. Go!
# Fill multiple fields then submit
opencli operate type 3 "hello" && opencli operate type 4 "world" && opencli operate click 7
# Open and inspect
opencli operate open https://example.com && opencli operate state
```
**Page-changing — always put last** in a chain (subsequent commands see stale indices):
- `open <url>`, `back`, `click <link/button that navigates>`
**Rule**: Chain when you already know the indices. Run `state` separately when you need to discover indices first.
## Core Workflow
1. **Navigate**: `opencli operate open <url>`
2. **Inspect**: `opencli operate state` see elements with `[N]` indices
2. **Inspect**: `opencli operate state` → elements with `[N]` indices
3. **Interact**: use indices — `click`, `type`, `select`, `keys`
4. **Wait**: `opencli operate wait selector ".loaded"` or `wait text "Success"`
5. **Verify**: `opencli operate get title` or `opencli operate screenshot`
4. **Wait** (if needed): `opencli operate wait selector ".loaded"` or `wait text "Success"`
5. **Verify**: `opencli operate state` or `opencli operate get value <N>`
6. **Repeat**: browser stays open between commands
7. **Save**: write a TS adapter to `~/.opencli/clis/<site>/<command>.ts`
@@ -43,26 +67,26 @@ opencli operate open https://example.com # 3. Go!
### Navigation
```bash
opencli operate open <url> # Open URL
opencli operate back # Go back
opencli operate open <url> # Open URL (page-changing)
opencli operate back # Go back (page-changing)
opencli operate scroll down # Scroll (up/down, --amount N)
opencli operate scroll up --amount 1000
```
### Inspect
### Inspect (free & instant)
```bash
opencli operate state # Elements with [N] indices
opencli operate screenshot [path.png] # Screenshot
opencli operate state # Structured DOM with [N] indices — PRIMARY tool
opencli operate screenshot [path.png] # Save visual to file — ONLY for user deliverables
```
### Get (structured data)
### Get (free & instant)
```bash
opencli operate get title # Page title
opencli operate get url # Current URL
opencli operate get text <index> # Element text content
opencli operate get value <index> # Input/textarea value
opencli operate get value <index> # Input/textarea value (use to verify after type)
opencli operate get html # Full page HTML
opencli operate get html --selector "h1" # Scoped HTML
opencli operate get attributes <index> # Element attributes
@@ -86,11 +110,16 @@ opencli operate wait text "Success" # Wait for text
opencli operate wait time 3 # Wait N seconds
```
### Extract
### Extract (free & instant, read-only)
Use `eval` ONLY for reading data. Never use it to click, type, or navigate.
```bash
opencli operate eval "document.title"
opencli operate eval "JSON.stringify([...document.querySelectorAll('h2')].map(e => e.textContent))"
# IMPORTANT: wrap complex logic in IIFE to avoid "already declared" errors
opencli operate eval "(function(){ const items = [...document.querySelectorAll('.item')]; return JSON.stringify(items.map(e => e.textContent)); })()"
```
### Network (API Discovery)
@@ -128,8 +157,7 @@ opencli operate close
```bash
opencli operate open https://httpbin.org/forms/post
opencli operate state # See [3] input "Customer Name", [4] input "Telephone"
opencli operate type 3 "OpenCLI"
opencli operate type 4 "555-0100"
opencli operate type 3 "OpenCLI" && opencli operate type 4 "555-0100"
opencli operate get value 3 # Verify: "OpenCLI"
opencli operate close
```
@@ -204,10 +232,19 @@ Save to `~/.opencli/clis/<site>/<command>.ts` → immediately available as `open
**Always prefer API over UI** — if you discovered an API during browsing, use `fetch()` directly.
## Tips
1. **Always `state` first** — never guess element indices, always inspect first
2. **Sessions persist** — browser stays open between commands, no need to re-open
3. **Use `eval` for data extraction**`eval "JSON.stringify(...)"` is faster than multiple `get` calls
4. **Use `network` to find APIs** — JSON APIs are more reliable than DOM scraping
5. **Alias**: `opencli op` is shorthand for `opencli operate`
## Troubleshooting
| Error | Fix |
|-------|-----|
| "Browser not connected" | Run `opencli doctor` |
| "attach failed: chrome-extension://" | Disable 1Password temporarily |
| Element not found | `opencli operate scroll down` then `opencli operate state` |
| Element not found | `opencli operate scroll down && opencli operate state` |
| Stale indices after page change | Run `opencli operate state` again to get fresh indices |
+25 -1
View File
@@ -35,12 +35,36 @@ export abstract class BasePage implements IPage {
abstract getCookies(opts?: { domain?: string; url?: string }): Promise<BrowserCookie[]>;
abstract screenshot(options?: ScreenshotOptions): Promise<string>;
abstract tabs(): Promise<unknown[]>;
abstract closeTab(index?: number): Promise<void>;
abstract newTab(): Promise<void>;
abstract selectTab(index: number): Promise<void>;
// ── Shared DOM helper implementations ──
async click(ref: string): Promise<void> {
await this.evaluate(clickJs(ref));
const result = await this.evaluate(clickJs(ref)) as
| string
| { status: string; x?: number; y?: number; w?: number; h?: number; error?: string }
| null;
// Backwards compat: old format returned 'clicked' string
if (typeof result === 'string' || result == null) return;
// JS click succeeded
if (result.status === 'clicked') return;
// JS click failed — try CDP native click if coordinates available
if (result.x != null && result.y != null) {
const success = await this.tryNativeClick(result.x, result.y);
if (success) return;
}
throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
}
/** Override in subclasses with CDP native click support */
protected async tryNativeClick(_x: number, _y: number): Promise<boolean> {
return false;
}
async typeText(ref: string, text: string): Promise<void> {
+8
View File
@@ -223,6 +223,14 @@ class CDPPage extends BasePage {
return [];
}
async closeTab(_index?: number): Promise<void> {
// Not supported in direct CDP mode
}
async newTab(): Promise<void> {
await this.bridge.send('Target.createTarget', { url: 'about:blank' });
}
async selectTab(_index: number): Promise<void> {
// Not supported in direct CDP mode
}
+50 -38
View File
@@ -5,64 +5,76 @@
* to eliminate code duplication for click, type, press, wait, scroll, etc.
*/
/** Generate JS to click an element by ref */
/** Shared element lookup JS fragment (4-strategy resolution) */
function resolveElementJs(safeRef: string, selectorSet: string): string {
return `
const ref = ${safeRef};
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
try { el = document.querySelector(ref); } catch {}
}
if (!el) {
const idx = parseInt(ref, 10);
if (!isNaN(idx)) {
el = document.querySelectorAll('${selectorSet}')[idx];
}
}`;
}
/** Generate JS to click an element by ref.
* Returns { status, x, y, w, h } for CDP fallback when JS click fails. */
export function clickJs(ref: string): string {
const safeRef = JSON.stringify(ref);
return `
(() => {
const ref = ${safeRef};
// 1. data-opencli-ref (set by snapshot engine)
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
// 2. data-ref (legacy)
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
// 3. CSS selector
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
try { el = document.querySelector(ref); } catch {}
}
// 4. Numeric index into interactive elements
if (!el) {
const idx = parseInt(ref, 10);
if (!isNaN(idx)) {
el = document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')[idx];
}
}
${resolveElementJs(safeRef, 'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')}
if (!el) throw new Error('Element not found: ' + ref);
el.scrollIntoView({ behavior: 'instant', block: 'center' });
el.click();
return 'clicked';
const rect = el.getBoundingClientRect();
const x = Math.round(rect.left + rect.width / 2);
const y = Math.round(rect.top + rect.height / 2);
try {
el.click();
return { status: 'clicked', x, y, w: Math.round(rect.width), h: Math.round(rect.height) };
} catch (e) {
return { status: 'js_failed', x, y, w: Math.round(rect.width), h: Math.round(rect.height), error: e.message };
}
})()
`;
}
/** Generate JS to type text into an element by ref */
/** Generate JS to type text into an element by ref.
* Uses native setter for React compat + execCommand for contenteditable. */
export function typeTextJs(ref: string, text: string): string {
const safeRef = JSON.stringify(ref);
const safeText = JSON.stringify(text);
return `
(() => {
const ref = ${safeRef};
// 1. data-opencli-ref (set by snapshot engine)
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
// 2. data-ref (legacy)
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
// 3. CSS selector
if (!el && ref.match(/^[a-zA-Z#.\\[]/)) {
try { el = document.querySelector(ref); } catch {}
}
// 4. Numeric index into typeable elements
if (!el) {
const idx = parseInt(ref, 10);
if (!isNaN(idx)) {
el = document.querySelectorAll('input, textarea, [contenteditable="true"]')[idx];
}
}
${resolveElementJs(safeRef, 'input, textarea, [contenteditable="true"]')}
if (!el) throw new Error('Element not found: ' + ref);
el.focus();
if (el.isContentEditable) {
el.textContent = ${safeText};
// Select all content + delete, then insert (supports undo, works with rich text editors)
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('delete', false);
document.execCommand('insertText', false, ${safeText});
el.dispatchEvent(new Event('input', { bubbles: true }));
} else {
el.value = ${safeText};
// Use native setter for React/framework compatibility (match element type)
const proto = el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (nativeSetter) {
nativeSetter.call(el, ${safeText});
} else {
el.value = ${safeText};
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
+21
View File
@@ -377,6 +377,8 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
if (role && INTERACTIVE_ROLES.has(role)) return true;
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true;
if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true;
// Framework event listener detection (React/Vue/Angular onClick)
if (hasFrameworkListener(el)) return true;
try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {}
if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true;
// Search element heuristic detection
@@ -384,6 +386,25 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string {
return false;
}
function hasFrameworkListener(el) {
try {
// React: __reactProps$xxx / __reactEvents$xxx with onClick/onMouseDown
for (const key of Object.keys(el)) {
if (key.startsWith('__reactProps$') || key.startsWith('__reactEvents$')) {
const props = el[key];
if (props && (props.onClick || props.onMouseDown || props.onPointerDown)) return true;
}
}
// Vue 3: _vei (Vue Event Invoker) with onClick
if (el._vei && (el._vei.onClick || el._vei.click || el._vei.onMousedown)) return true;
// Vue 2: __vue__ instance with $listeners
if (el.__vue__?.$listeners?.click) return true;
// Angular: ng-reflect-click binding
if (el.hasAttribute('ng-reflect-click')) return true;
} catch { /* ignore errors from cross-origin or frozen objects */ }
return false;
}
function isSearchElement(el) {
// Check class names for search indicators
const className = el.className?.toLowerCase() || '';
+84
View File
@@ -129,6 +129,18 @@ export class Page extends BasePage {
return Array.isArray(result) ? result : [];
}
async closeTab(index?: number): Promise<void> {
await sendCommand('tabs', { op: 'close', ...this._wsOpt(), ...(index !== undefined ? { index } : {}) });
// Invalidate cached tabId — the closed tab might have been our active one.
// We can't know for sure (close-by-index doesn't return tabId), so reset.
this._tabId = undefined;
}
async newTab(): Promise<void> {
const result = await sendCommand('tabs', { op: 'new', ...this._wsOpt() }) as { tabId?: number };
if (result?.tabId) this._tabId = result.tabId;
}
async selectTab(index: number): Promise<void> {
const result = await sendCommand('tabs', { op: 'select', index, ...this._wsOpt() }) as { selected?: number };
if (result?.selected) this._tabId = result.selected;
@@ -176,6 +188,78 @@ export class Page extends BasePage {
});
}
/** CDP native click fallback — called when JS el.click() fails */
protected override async tryNativeClick(x: number, y: number): Promise<boolean> {
try {
await this.nativeClick(x, y);
return true;
} catch {
return false;
}
}
/** Precise click using DOM.getContentQuads/getBoxModel for inline elements */
async clickWithQuads(ref: string): Promise<void> {
const safeRef = JSON.stringify(ref);
const cssSelector = `[data-opencli-ref="${ref.replace(/"/g, '\\"')}"]`;
// Scroll element into view first
await this.evaluate(`
(() => {
const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
return !!el;
})()
`);
try {
// Find DOM node via CDP
const doc = await this.cdp('DOM.getDocument', {}) as { root: { nodeId: number } };
const result = await this.cdp('DOM.querySelectorAll', {
nodeId: doc.root.nodeId,
selector: cssSelector,
}) as { nodeIds: number[] };
if (!result.nodeIds?.length) throw new Error('DOM node not found');
const nodeId = result.nodeIds[0];
// Try getContentQuads first (precise for inline elements)
try {
const quads = await this.cdp('DOM.getContentQuads', { nodeId }) as { quads: number[][] };
if (quads.quads?.length) {
const q = quads.quads[0];
const cx = (q[0] + q[2] + q[4] + q[6]) / 4;
const cy = (q[1] + q[3] + q[5] + q[7]) / 4;
await this.nativeClick(Math.round(cx), Math.round(cy));
return;
}
} catch { /* fallthrough */ }
// Try getBoxModel
try {
const box = await this.cdp('DOM.getBoxModel', { nodeId }) as { model: { content: number[] } };
if (box.model?.content) {
const c = box.model.content;
const cx = (c[0] + c[2] + c[4] + c[6]) / 4;
const cy = (c[1] + c[3] + c[5] + c[7]) / 4;
await this.nativeClick(Math.round(cx), Math.round(cy));
return;
}
} catch { /* fallthrough */ }
} catch { /* fallthrough */ }
// Final fallback: regular click
await this.evaluate(`
(() => {
const el = document.querySelector('[data-opencli-ref="' + ${safeRef} + '"]');
if (!el) throw new Error('Element not found: ' + ${safeRef});
el.click();
return 'clicked';
})()
`);
}
async nativeClick(x: number, y: number): Promise<void> {
await this.cdp('Input.dispatchMouseEvent', {
type: 'mousePressed',
+17 -1
View File
@@ -372,7 +372,23 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
await page.click(index);
await page.wait(0.3);
await page.typeText(index, text);
console.log(`Typed "${text}" into element [${index}]`);
// Detect autocomplete/combobox fields and wait for dropdown suggestions
const isAutocomplete = await page.evaluate(`
(() => {
const el = document.querySelector('[data-opencli-ref="${index}"]');
if (!el) return false;
const role = el.getAttribute('role');
const ac = el.getAttribute('aria-autocomplete');
const list = el.getAttribute('list');
return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
})()
`);
if (isAutocomplete) {
await page.wait(0.4);
console.log(`Typed "${text}" into autocomplete [${index}] — use state to see suggestions`);
} else {
console.log(`Typed "${text}" into element [${index}]`);
}
}));
operate.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
@@ -8,6 +8,7 @@ function makePage(result: unknown): IPage {
getCookies: vi.fn(), snapshot: vi.fn(), click: vi.fn(),
typeText: vi.fn(), pressKey: vi.fn(), scrollTo: vi.fn(),
getFormState: vi.fn(), wait: vi.fn(), tabs: vi.fn(),
closeTab: vi.fn(), newTab: vi.fn(), selectTab: vi.fn(),
networkRequests: vi.fn(), consoleMessages: vi.fn(),
scroll: vi.fn(), autoScroll: vi.fn(),
installInterceptor: vi.fn(), getInterceptedRequests: vi.fn(),
@@ -19,6 +19,8 @@ function makePage(): IPage {
getFormState: vi.fn(),
wait: vi.fn(),
tabs: vi.fn(),
closeTab: vi.fn(),
newTab: vi.fn(),
selectTab: vi.fn(),
networkRequests: vi.fn(),
consoleMessages: vi.fn(),
+2
View File
@@ -86,6 +86,8 @@ function createPageMock(
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -32,6 +32,8 @@ function createMockPage(): IPage {
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn(),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn(),
newTab: vi.fn(),
selectTab: vi.fn(),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue(''),
+2
View File
@@ -14,6 +14,8 @@ function createPageMock(evaluateResult: unknown): IPage {
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -15,6 +15,8 @@ function createPageMock(evaluateResult: any): IPage {
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
@@ -22,6 +22,8 @@ function createPageMock(evaluateResult: any): IPage {
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
@@ -26,6 +26,8 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -29,6 +29,8 @@ function createPageMock(evaluateResult: any): IPage {
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -16,6 +16,8 @@ function createPageMock(evaluateResult: any): IPage {
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -25,6 +25,8 @@ function createPageMock(evaluateResults: any[], overrides: Partial<IPage> = {}):
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -20,6 +20,8 @@ function createPageMock(evaluateResults: any[]): IPage {
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -20,6 +20,8 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn(),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn(),
newTab: vi.fn(),
selectTab: vi.fn(),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue(''),
+2
View File
@@ -34,6 +34,8 @@ function createMockPage(getCookies: IPage['getCookies']): IPage {
getFormState: vi.fn().mockResolvedValue({}),
wait: vi.fn(),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn(),
newTab: vi.fn(),
selectTab: vi.fn(),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
+2
View File
@@ -56,6 +56,8 @@ export interface IPage {
getFormState(): Promise<any>;
wait(options: number | WaitOptions): Promise<void>;
tabs(): Promise<any>;
closeTab(index?: number): Promise<void>;
newTab(): Promise<void>;
selectTab(index: number): Promise<void>;
networkRequests(includeStatic?: boolean): Promise<any>;
consoleMessages(level?: string): Promise<any>;