Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 025583f666 | |||
| b65dbbf6ea | |||
| 6dfc90f9c6 | |||
| b7be39d134 | |||
| bbe7495bca |
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 |
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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() || '';
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user