explorbot 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +37 -1
  2. package/bin/explorbot-cli.ts +27 -18
  3. package/dist/bin/explorbot-cli.js +26 -18
  4. package/dist/package.json +3 -3
  5. package/dist/rules/navigator/output.md +9 -0
  6. package/dist/rules/navigator/verification-actions.md +2 -0
  7. package/dist/src/action-result.js +23 -1
  8. package/dist/src/action.js +51 -42
  9. package/dist/src/ai/bosun.js +11 -1
  10. package/dist/src/ai/conversation.js +39 -0
  11. package/dist/src/ai/historian/codeceptjs.js +109 -0
  12. package/dist/src/ai/historian/experience.js +321 -0
  13. package/dist/src/ai/historian/mixin.js +2 -0
  14. package/dist/src/ai/historian/playwright.js +145 -0
  15. package/dist/src/ai/historian/screencast.js +121 -0
  16. package/dist/src/ai/historian/utils.js +18 -0
  17. package/dist/src/ai/historian.js +21 -405
  18. package/dist/src/ai/navigator.js +82 -29
  19. package/dist/src/ai/pilot.js +232 -13
  20. package/dist/src/ai/planner.js +29 -9
  21. package/dist/src/ai/provider.js +54 -17
  22. package/dist/src/ai/researcher.js +41 -32
  23. package/dist/src/ai/rules.js +26 -14
  24. package/dist/src/ai/tester.js +90 -26
  25. package/dist/src/ai/tools.js +13 -7
  26. package/dist/src/browser-server.js +16 -3
  27. package/dist/src/commands/add-rule-command.js +11 -8
  28. package/dist/src/commands/clean-command.js +2 -1
  29. package/dist/src/commands/explore-command.js +43 -15
  30. package/dist/src/commands/init-command.js +9 -8
  31. package/dist/src/commands/plan-command.js +32 -0
  32. package/dist/src/commands/plan-save-command.js +19 -7
  33. package/dist/src/commands/rerun-command.js +4 -0
  34. package/dist/src/components/App.js +15 -5
  35. package/dist/src/execution-controller.js +13 -2
  36. package/dist/src/experience-tracker.js +20 -64
  37. package/dist/src/explorbot.js +8 -8
  38. package/dist/src/explorer.js +11 -3
  39. package/dist/src/observability.js +50 -99
  40. package/dist/src/playwright-recorder.js +309 -0
  41. package/dist/src/reporter.js +4 -1
  42. package/dist/src/test-plan.js +12 -0
  43. package/dist/src/utils/aria.js +37 -1
  44. package/dist/src/utils/error-page.js +20 -7
  45. package/dist/src/utils/next-steps.js +37 -0
  46. package/dist/src/utils/strings.js +15 -0
  47. package/package.json +3 -3
  48. package/rules/navigator/output.md +9 -0
  49. package/rules/navigator/verification-actions.md +2 -0
  50. package/src/action-result.ts +26 -1
  51. package/src/action.ts +49 -41
  52. package/src/ai/bosun.ts +11 -1
  53. package/src/ai/conversation.ts +37 -0
  54. package/src/ai/historian/codeceptjs.ts +130 -0
  55. package/src/ai/historian/experience.ts +384 -0
  56. package/src/ai/historian/mixin.ts +4 -0
  57. package/src/ai/historian/playwright.ts +169 -0
  58. package/src/ai/historian/screencast.ts +133 -0
  59. package/src/ai/historian/utils.ts +23 -0
  60. package/src/ai/historian.ts +37 -473
  61. package/src/ai/navigator.ts +82 -29
  62. package/src/ai/pilot.ts +237 -14
  63. package/src/ai/planner.ts +29 -9
  64. package/src/ai/provider.ts +51 -17
  65. package/src/ai/researcher.ts +45 -33
  66. package/src/ai/rules.ts +27 -14
  67. package/src/ai/tester.ts +94 -26
  68. package/src/ai/tools.ts +47 -25
  69. package/src/browser-server.ts +17 -3
  70. package/src/commands/add-rule-command.ts +11 -7
  71. package/src/commands/clean-command.ts +2 -1
  72. package/src/commands/explore-command.ts +46 -14
  73. package/src/commands/init-command.ts +9 -8
  74. package/src/commands/plan-command.ts +35 -0
  75. package/src/commands/plan-save-command.ts +18 -7
  76. package/src/commands/rerun-command.ts +5 -0
  77. package/src/components/App.tsx +16 -5
  78. package/src/config.ts +12 -1
  79. package/src/execution-controller.ts +14 -3
  80. package/src/experience-tracker.ts +21 -72
  81. package/src/explorbot.ts +8 -8
  82. package/src/explorer.ts +13 -3
  83. package/src/observability.ts +50 -109
  84. package/src/playwright-recorder.ts +305 -0
  85. package/src/reporter.ts +4 -1
  86. package/src/test-plan.ts +12 -0
  87. package/src/utils/aria.ts +38 -1
  88. package/src/utils/error-page.ts +22 -7
  89. package/src/utils/next-steps.ts +51 -0
  90. package/src/utils/strings.ts +17 -0
@@ -7,6 +7,7 @@ export const CLEAN_TARGETS = {
7
7
  states: { description: 'page states', getDir: () => outputPath('states') },
8
8
  research: { description: 'research cache', getDir: () => outputPath('research') },
9
9
  plans: { description: 'test plans', getDir: () => outputPath('plans') },
10
+ tests: { description: 'generated tests', getDir: () => outputPath('tests') },
10
11
  experiences: { description: 'experience files', getDir: () => getExperienceDir() },
11
12
  output: { description: 'all output files', getDir: () => outputPath() },
12
13
  };
@@ -38,7 +39,7 @@ function cleanDirectoryContents(dirPath) {
38
39
  }
39
40
  export class CleanCommand extends BaseCommand {
40
41
  name = 'clean';
41
- description = 'Clean files: clean [states|research|plans|experiences|output]';
42
+ description = 'Clean files: clean [states|research|plans|tests|experiences|output]';
42
43
  suggestions = Object.entries(CLEAN_TARGETS).map(([name, target]) => ({ command: `clean ${name}`, hint: `clean ${target.description}` }));
43
44
  async execute(args) {
44
45
  const target = args.trim().toLowerCase();
@@ -1,11 +1,13 @@
1
- import path from 'node:path';
2
1
  import figureSet from 'figures';
3
2
  import { getStyles } from '../ai/planner/styles.js';
3
+ import { outputPath } from '../config.js';
4
4
  import { Stats } from '../stats.js';
5
5
  import { getCliName } from "../utils/cli-name.js";
6
6
  import { ErrorPageError } from "../utils/error-page.js";
7
7
  import { tag } from '../utils/logger.js';
8
8
  import { jsonToTable } from '../utils/markdown-parser.js';
9
+ import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
10
+ import { safeFilename } from "../utils/strings.js";
9
11
  import { BaseCommand } from './base-command.js';
10
12
  export class ExploreCommand extends BaseCommand {
11
13
  name = 'explore';
@@ -65,8 +67,8 @@ export class ExploreCommand extends BaseCommand {
65
67
  if (mainUrl)
66
68
  await this.explorBot.visit(mainUrl);
67
69
  const savedPath = this.explorBot.savePlans(this.completedPlans);
68
- this.printResults(savedPath);
69
- this.printRerunSuggestions();
70
+ this.printResults();
71
+ this.printNextSteps(savedPath);
70
72
  }
71
73
  async runAllStyles(pageUrl, feature, parentPlan, completedPlans) {
72
74
  let fresh = true;
@@ -97,7 +99,7 @@ export class ExploreCommand extends BaseCommand {
97
99
  tag('warning').log(`Planning style '${opts.style}' failed after retry, skipping`);
98
100
  }
99
101
  }
100
- printResults(savedPath) {
102
+ printResults() {
101
103
  const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title })));
102
104
  if (allTests.length === 0)
103
105
  return;
@@ -128,20 +130,46 @@ export class ExploreCommand extends BaseCommand {
128
130
  columns.push('Plan');
129
131
  tag('multiline').log(jsonToTable(rows, columns));
130
132
  tag('info').log(`${figureSet.tick} ${allTests.length} tests completed`);
131
- if (savedPath) {
132
- const relativePath = path.relative(process.cwd(), savedPath);
133
- tag('info').log(`Re-run tests: ${getCliName()} test ${relativePath} <index>`);
134
- }
135
133
  }
136
- printRerunSuggestions() {
134
+ printNextSteps(savedPlanPath) {
135
+ const cli = getCliName();
136
+ const sections = [];
137
+ if (savedPlanPath) {
138
+ const relPlan = relativeToCwd(savedPlanPath);
139
+ sections.push({
140
+ label: 'Plan',
141
+ path: savedPlanPath,
142
+ commands: [
143
+ { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
144
+ { label: 'Run all', command: `${cli} test ${relPlan} *` },
145
+ { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
146
+ ],
147
+ });
148
+ }
137
149
  const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
138
- if (savedFiles.length === 0)
139
- return;
140
- for (const filePath of savedFiles) {
141
- tag('info').log(`Generated: ${path.basename(filePath)}`);
150
+ const screencasts = savedFiles.filter((f) => f.endsWith('.webm'));
151
+ const testFiles = savedFiles.filter((f) => !f.endsWith('.webm'));
152
+ if (testFiles.length > 0) {
153
+ const commands = testFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
154
+ commands.push({ label: 'List tests', command: `${cli} runs` });
155
+ sections.push({
156
+ label: `Generated tests (${testFiles.length})`,
157
+ commands,
158
+ });
159
+ }
160
+ if (screencasts.length > 0) {
161
+ const commands = screencasts.map((f) => ({ label: '', command: relativeToCwd(f) }));
162
+ const screencastDir = relativeToCwd(outputPath('screencasts'));
163
+ const planSlugs = [...new Set(this.completedPlans.map((p) => safeFilename(p.title)).filter(Boolean))];
164
+ for (const slug of planSlugs) {
165
+ commands.push({ label: 'Browse plan', command: `ls ${screencastDir}/${slug}-*` });
166
+ }
167
+ sections.push({
168
+ label: `Screencasts (${screencasts.length})`,
169
+ commands,
170
+ });
142
171
  }
143
- tag('info').log(`List tests: ${getCliName()} runs`);
144
- tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
172
+ printNextSteps(sections);
145
173
  }
146
174
  isLimitReached() {
147
175
  return this.maxTests != null && this.testsRun >= this.maxTests;
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import dedent from 'dedent';
5
5
  import { getCliName } from "../utils/cli-name.js";
6
6
  import { log, tag } from '../utils/logger.js';
7
+ import { relativeToCwd } from "../utils/next-steps.js";
7
8
  const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
8
9
  // import { '<your provider here>' } from '<your provider package here>';
9
10
 
@@ -57,10 +58,10 @@ export function runInitCommand(options) {
57
58
  const dir = resolve(customPath);
58
59
  if (!existsSync(dir)) {
59
60
  mkdirSync(dir, { recursive: true });
60
- log(`Created directory: ${dir}`);
61
+ log(`Created directory: ${relativeToCwd(dir)}`);
61
62
  }
62
63
  process.chdir(dir);
63
- log(`Working in directory: ${dir}`);
64
+ log(`Working in directory: ${relativeToCwd(dir)}`);
64
65
  }
65
66
  try {
66
67
  let outPath = resolve(configPath);
@@ -73,22 +74,22 @@ export function runInitCommand(options) {
73
74
  const dir = dirname(outPath);
74
75
  if (!existsSync(dir)) {
75
76
  mkdirSync(dir, { recursive: true });
76
- log(`Created directory: ${dir}`);
77
+ log(`Created directory: ${relativeToCwd(dir)}`);
77
78
  }
78
79
  if (existsSync(outPath) && !force) {
79
- log(`Config file already exists: ${outPath}`);
80
+ log(`Config file already exists: ${relativeToCwd(outPath)}`);
80
81
  log('Use --force to overwrite existing file');
81
82
  process.exit(1);
82
83
  }
83
84
  writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
84
- log(`Created config file: ${outPath}`);
85
+ log(`Created config file: ${relativeToCwd(outPath)}`);
85
86
  const envPath = resolve(process.cwd(), '.env');
86
87
  if (!existsSync(envPath)) {
87
88
  writeFileSync(envPath, `${DEFAULT_ENV_TEMPLATE}\n`, 'utf8');
88
- log(`Created env file: ${envPath}`);
89
+ log(`Created env file: ${relativeToCwd(envPath)}`);
89
90
  }
90
91
  else {
91
- log(`Env file already exists: ${envPath}`);
92
+ log(`Env file already exists: ${relativeToCwd(envPath)}`);
92
93
  }
93
94
  log('');
94
95
  log('Next steps:');
@@ -102,7 +103,7 @@ export function runInitCommand(options) {
102
103
  tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
103
104
  if (!existsSync('./output')) {
104
105
  mkdirSync('./output', { recursive: true });
105
- log('Created directory: ./output');
106
+ log('Created directory: output');
106
107
  }
107
108
  }
108
109
  catch (error) {
@@ -1,7 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  import figureSet from 'figures';
4
+ import { getCliName } from "../utils/cli-name.js";
4
5
  import { tag } from '../utils/logger.js';
6
+ import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
5
7
  import { BaseCommand } from './base-command.js';
6
8
  export class PlanCommand extends BaseCommand {
7
9
  name = 'plan';
@@ -34,6 +36,36 @@ export class PlanCommand extends BaseCommand {
34
36
  }
35
37
  this.printPlanSummary();
36
38
  this.updateSuggestions();
39
+ this.printNextSteps();
40
+ }
41
+ printNextSteps() {
42
+ const savedPath = this.explorBot.lastSavedPlanPath;
43
+ if (!savedPath)
44
+ return;
45
+ const cli = getCliName();
46
+ const relPlan = relativeToCwd(savedPath);
47
+ const sections = [
48
+ {
49
+ label: 'Plan',
50
+ path: savedPath,
51
+ commands: [
52
+ { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
53
+ { label: 'Run all', command: `${cli} test ${relPlan} *` },
54
+ { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
55
+ { label: 'Reload', command: `/plan:load ${relPlan}` },
56
+ ],
57
+ },
58
+ ];
59
+ const suite = this.explorBot.getSuite();
60
+ const files = suite && suite.automatedTestCount > 0 ? suite.getAutomatedTestFiles() : [];
61
+ if (files.length > 0) {
62
+ const commands = files.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
63
+ sections.push({
64
+ label: `Automated tests (${files.length})`,
65
+ commands,
66
+ });
67
+ }
68
+ printNextSteps(sections);
37
69
  }
38
70
  printPlanSummary() {
39
71
  const suite = this.explorBot.getSuite();
@@ -1,5 +1,5 @@
1
- import path from 'node:path';
2
- import { tag } from '../utils/logger.js';
1
+ import { getCliName } from "../utils/cli-name.js";
2
+ import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
3
3
  import { BaseCommand } from './base-command.js';
4
4
  export class PlanSaveCommand extends BaseCommand {
5
5
  name = 'plan:save';
@@ -12,10 +12,22 @@ export class PlanSaveCommand extends BaseCommand {
12
12
  }
13
13
  const filename = args.trim() || undefined;
14
14
  const savedPath = this.explorBot.savePlan(filename);
15
- if (savedPath) {
16
- const relativePath = path.relative(process.cwd(), savedPath);
17
- tag('success').log(`Plan saved to: ${relativePath}`);
18
- tag('info').log(`Run /plan:load ${relativePath} to reload it`);
19
- }
15
+ if (!savedPath)
16
+ return;
17
+ const cli = getCliName();
18
+ const relPlan = relativeToCwd(savedPath);
19
+ const sections = [
20
+ {
21
+ label: 'Plan',
22
+ path: savedPath,
23
+ commands: [
24
+ { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
25
+ { label: 'Run all', command: `${cli} test ${relPlan} *` },
26
+ { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
27
+ { label: 'Reload', command: `/plan:load ${relPlan}` },
28
+ ],
29
+ },
30
+ ];
31
+ printNextSteps(sections);
20
32
  }
21
33
  }
@@ -19,6 +19,10 @@ export class RerunCommand extends BaseCommand {
19
19
  if (!existsSync(filePath)) {
20
20
  filePath = resolve(ConfigParser.getInstance().getTestsDir(), filename);
21
21
  }
22
+ if (filePath.endsWith('.spec.ts') || filePath.endsWith('.spec.js')) {
23
+ tag('error').log(`Rerun does not support Playwright tests. Run them with: npx playwright test ${filePath}`);
24
+ return;
25
+ }
22
26
  const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
23
27
  await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
24
28
  }
@@ -72,16 +72,27 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
72
72
  setInterruptPrompt(prompt);
73
73
  setShowInput(true);
74
74
  return new Promise((resolve) => {
75
- interruptResolveRef.current = resolve;
75
+ interruptResolveRef.current = (value) => {
76
+ interruptResolveRef.current = null;
77
+ setInterruptPrompt(null);
78
+ resolve(value);
79
+ };
76
80
  });
77
81
  });
78
82
  const handleIdle = () => {
79
83
  setShowInput(true);
80
84
  };
85
+ const handleInterrupt = () => {
86
+ if (interruptResolveRef.current) {
87
+ interruptResolveRef.current(null);
88
+ }
89
+ };
81
90
  executionController.on('idle', handleIdle);
91
+ executionController.on('interrupt', handleInterrupt);
82
92
  setInputCallbackReady(true);
83
93
  return () => {
84
94
  executionController.off('idle', handleIdle);
95
+ executionController.off('interrupt', handleInterrupt);
85
96
  executionController.reset();
86
97
  };
87
98
  }, []);
@@ -244,9 +255,10 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
244
255
  return;
245
256
  }
246
257
  if (isCommand) {
247
- setInterruptPrompt(null);
258
+ if (interruptResolveRef.current) {
259
+ interruptResolveRef.current(null);
260
+ }
248
261
  setShowInput(false);
249
- interruptResolveRef.current = null;
250
262
  executionController.startExecution();
251
263
  try {
252
264
  await commandHandler.executeCommand(trimmed);
@@ -264,8 +276,6 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
264
276
  }
265
277
  if (interruptResolveRef.current) {
266
278
  interruptResolveRef.current(input);
267
- interruptResolveRef.current = null;
268
- setInterruptPrompt(null);
269
279
  setShowInput(false);
270
280
  return;
271
281
  }
@@ -7,6 +7,7 @@ export class ExecutionController extends EventEmitter {
7
7
  inputCallback = null;
8
8
  interruptResolvers = [];
9
9
  abortController = null;
10
+ awaitingInput = false;
10
11
  constructor() {
11
12
  super();
12
13
  }
@@ -39,6 +40,9 @@ export class ExecutionController extends EventEmitter {
39
40
  this.interruptResolvers = [];
40
41
  this.emit('idle');
41
42
  }
43
+ isAwaitingInput() {
44
+ return this.awaitingInput;
45
+ }
42
46
  isInterrupted() {
43
47
  return this.interrupted;
44
48
  }
@@ -64,10 +68,16 @@ export class ExecutionController extends EventEmitter {
64
68
  return userInput;
65
69
  }
66
70
  async requestInput(prompt) {
67
- if (this.inputCallback) {
71
+ if (!this.inputCallback) {
72
+ return await this.readlineInput(prompt);
73
+ }
74
+ this.awaitingInput = true;
75
+ try {
68
76
  return await this.inputCallback(prompt);
69
77
  }
70
- return await this.readlineInput(prompt);
78
+ finally {
79
+ this.awaitingInput = false;
80
+ }
71
81
  }
72
82
  async readlineInput(prompt) {
73
83
  const rl = readline.createInterface({
@@ -86,6 +96,7 @@ export class ExecutionController extends EventEmitter {
86
96
  this.interrupted = false;
87
97
  this.interruptResolvers = [];
88
98
  this.abortController = null;
99
+ this.awaitingInput = false;
89
100
  }
90
101
  }
91
102
  export const executionController = ExecutionController.getInstance();
@@ -13,15 +13,17 @@ export const RECENT_WINDOW_DAYS = 30;
13
13
  /**
14
14
  * Stores and reads per-page experience files (`./experience/<stateHash>.md`).
15
15
  *
16
- * Format rules (enforced by writeFlow/writeAction — the only supported writers):
16
+ * Two writers, two contracts:
17
17
  *
18
- * ## FLOW: <imperative title> multi-step, `*` bullets + optional ```js``` + `>` discovery, ends with `---`
19
- * ## ACTION: <imperative title> single-step, optional `Solution:` line + one ```js``` code block
18
+ * writeFlow(state, body, relatedUrls?) — caller hands in a fully-formatted
19
+ * `## FLOW: <imperative title>` block (multi-step,
20
+ * `*` bullets + optional ```js``` + `>` discovery,
21
+ * ends with `---`). Tracker dedups + prepends.
22
+ * writeAction(state, ActionInput) — `## ACTION: <imperative title>`, single-step,
23
+ * optional `Solution:` line + one ```js``` code block.
24
+ * Title normalized via normalizeTitle().
20
25
  *
21
26
  * - Always h2. Never h3 for FLOW/ACTION.
22
- * - Title is an imperative verb phrase, lowercase-first, no trailing punctuation.
23
- * Writers normalize automatically (strip own `FLOW:` / `ACTION:` prefix if the caller included it,
24
- * lowercase first char, trim trailing `.!?,;:`).
25
27
  * - On read (getSuccessfulExperience), headings are rendered as
26
28
  * `## HOW to <title> (multi-step|single-step)` so prompts get natural phrasing.
27
29
  */
@@ -159,27 +161,25 @@ export class ExperienceTracker {
159
161
  this.writeExperienceFile(stateHash, updatedContent, data);
160
162
  tag('substep').log(` Added ACTION to: ${stateHash}.md`);
161
163
  }
162
- writeFlow(state, flow) {
164
+ writeFlow(state, body, relatedUrls) {
163
165
  if (this.disabled || this.isWritingDisabled(state))
164
166
  return;
165
- if (!flow.steps?.length)
167
+ if (!body?.trim())
166
168
  return;
167
169
  this.ensureExperienceFile(state);
168
170
  const stateHash = state.getStateHash();
169
171
  const { content, data } = this.readExperienceFile(stateHash);
170
- if (flow.relatedUrls?.length) {
172
+ if (content.includes(body)) {
173
+ debugLog('Skipping duplicate flow body');
174
+ return;
175
+ }
176
+ if (relatedUrls?.length) {
171
177
  const currentPath = extractStatePath(state.url || '');
172
178
  const existingRelated = Array.isArray(data.related) ? data.related : [];
173
- const allRelated = [...new Set([...existingRelated, ...flow.relatedUrls])];
179
+ const allRelated = [...new Set([...existingRelated, ...relatedUrls])];
174
180
  data.related = allRelated.filter((url) => url !== currentPath);
175
181
  }
176
- const title = normalizeTitle(flow.scenario);
177
- if (!title)
178
- return;
179
- const sessionContent = this.trimSessionContent(generateFlowContent(title, flow.steps));
180
- if (!sessionContent)
181
- return;
182
- const updatedContent = `${sessionContent}\n${content}`;
182
+ const updatedContent = `${body}\n${content}`;
183
183
  this.writeExperienceFile(stateHash, updatedContent, data);
184
184
  tag('substep').log(`Added FLOW to: ${stateHash}.md`);
185
185
  }
@@ -245,33 +245,6 @@ export class ExperienceTracker {
245
245
  // Clear any in-memory state if needed
246
246
  // The actual files will be cleaned up by test cleanup
247
247
  }
248
- trimSessionContent(content) {
249
- const q = mdq(content);
250
- if (q.query('heading').count() === 0)
251
- return null;
252
- if (q.query('code').count() === 0)
253
- return null;
254
- let result = content;
255
- const codeBlocks = q.query('code').each();
256
- if (codeBlocks.length > 2) {
257
- for (const block of codeBlocks.slice(2)) {
258
- result = result.replace(block.text(), '');
259
- }
260
- }
261
- const blockquotes = mdq(result).query('blockquote').each();
262
- if (blockquotes.length > 5) {
263
- for (const bq of blockquotes.slice(5)) {
264
- result = result.replace(bq.text(), '');
265
- }
266
- }
267
- const lines = result.split('\n');
268
- if (lines.length > 40) {
269
- result = lines.slice(0, 40).join('\n');
270
- }
271
- if (!result.trim())
272
- return null;
273
- return result;
274
- }
275
248
  getSuccessfulExperience(state, options) {
276
249
  const records = this.getRelevantExperience(state, {
277
250
  includeDescendantExperience: options?.includeDescendants,
@@ -469,7 +442,9 @@ export function renderExperienceToc(toc) {
469
442
  return '';
470
443
  const lines = [];
471
444
  lines.push('<experience>');
472
- lines.push('Past experience for this page — reusable recipes recorded from prior successful runs.');
445
+ lines.push('Past experience for this page — recipes recorded from prior successful runs.');
446
+ lines.push('Locators and step ordering worked then; the page may have changed since.');
447
+ lines.push('Treat as a starting hypothesis, not ground truth. If a step fails, fall back to ARIA/UI-map.');
473
448
  lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
474
449
  lines.push('Call learn_experience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
475
450
  lines.push('');
@@ -513,25 +488,6 @@ function generateActionContent(title, code, explanation) {
513
488
  lines.push('');
514
489
  return lines.join('\n');
515
490
  }
516
- function generateFlowContent(title, steps) {
517
- let content = `## FLOW: ${title}\n\n`;
518
- for (const step of steps) {
519
- content += `* ${step.message}\n\n`;
520
- if (step.code) {
521
- content += '```js\n';
522
- content += `${step.code}\n`;
523
- content += '```\n\n';
524
- }
525
- if (step.discovery) {
526
- const discoveries = step.discovery.split('\n').filter((d) => d.trim());
527
- for (const discovery of discoveries) {
528
- content += `> ${discovery.trim()}\n\n`;
529
- }
530
- }
531
- }
532
- content += '---\n';
533
- return content;
534
- }
535
491
  function renderAsHowTo(content) {
536
492
  const tokens = marked.lexer(content);
537
493
  let result = '';
@@ -36,6 +36,7 @@ export class ExplorBot {
36
36
  currentPlan;
37
37
  planFeature;
38
38
  lastPlanError = null;
39
+ lastSavedPlanPath = null;
39
40
  agents = {};
40
41
  constructor(options = {}) {
41
42
  this.options = options;
@@ -214,10 +215,13 @@ export class ExplorBot {
214
215
  return this.agents.quartermaster;
215
216
  }
216
217
  agentHistorian() {
217
- return (this.agents.historian ||= this.createAgent(({ ai, explorer }) => {
218
+ return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
218
219
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
219
220
  const reporter = explorer.getReporter();
220
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager());
221
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, {
222
+ recorder: explorer.getPlaywrightRecorder(),
223
+ helper: explorer.playwrightHelper,
224
+ });
221
225
  }));
222
226
  }
223
227
  agentRerunner() {
@@ -314,12 +318,7 @@ export class ExplorBot {
314
318
  return undefined;
315
319
  return this.currentPlan;
316
320
  }
317
- const savedPath = this.savePlan();
318
- if (savedPath) {
319
- const relativePath = path.relative(process.cwd(), savedPath);
320
- tag('info').log(`Plan saved to: ${relativePath}`);
321
- tag('info').log(`Edit the plan file and run /plan:load ${relativePath} to reload it`);
322
- }
321
+ this.savePlan();
323
322
  return this.currentPlan;
324
323
  }
325
324
  getPlansDir() {
@@ -341,6 +340,7 @@ export class ExplorBot {
341
340
  const planFilename = filename || this.generatePlanFilename();
342
341
  const planPath = path.join(plansDir, planFilename);
343
342
  Plan.saveMultipleToMarkdown(plans, planPath);
343
+ this.lastSavedPlanPath = planPath;
344
344
  return planPath;
345
345
  }
346
346
  generatePlanFilename() {
@@ -12,6 +12,7 @@ import { RequestStore } from "./api/request-store.js";
12
12
  import { XhrCapture } from "./api/xhr-capture.js";
13
13
  import { ConfigParser, outputPath } from './config.js';
14
14
  import { KnowledgeTracker } from './knowledge-tracker.js';
15
+ import { PlaywrightRecorder } from "./playwright-recorder.js";
15
16
  import { Reporter } from "./reporter.js";
16
17
  import { StateManager } from './state-manager.js';
17
18
  import { createDebug, log, tag } from './utils/logger.js';
@@ -35,6 +36,7 @@ class Explorer {
35
36
  _activeTest = null;
36
37
  xhrCapture = null;
37
38
  requestStore = null;
39
+ playwrightRecorder = new PlaywrightRecorder();
38
40
  constructor(config, aiProvider, options) {
39
41
  this.config = config;
40
42
  this.aiProvider = aiProvider;
@@ -89,7 +91,7 @@ class Explorer {
89
91
  tag('substep').log(debugInfo);
90
92
  }
91
93
  const PlaywrightConfig = {
92
- timeout: 1000,
94
+ timeout: 3000,
93
95
  highlightElement: true,
94
96
  waitForAction: 500,
95
97
  ...playwrightConfig,
@@ -188,6 +190,7 @@ class Explorer {
188
190
  const hasSession = this.options?.session && existsSync(this.options.session);
189
191
  const contextOptions = hasSession ? { storageState: this.options.session } : undefined;
190
192
  await this.playwrightHelper._createContextPage(contextOptions);
193
+ await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
191
194
  this.setupXhrCapture();
192
195
  if (hasSession) {
193
196
  tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options.session)}`);
@@ -216,7 +219,10 @@ class Explorer {
216
219
  await this.playwrightHelper._startBrowser();
217
220
  }
218
221
  createAction() {
219
- return new Action(this.actor, this.stateManager);
222
+ return new Action(this.actor, this.stateManager, this.playwrightRecorder);
223
+ }
224
+ getPlaywrightRecorder() {
225
+ return this.playwrightRecorder;
220
226
  }
221
227
  async visit(url) {
222
228
  await this.closeOtherTabs();
@@ -411,6 +417,7 @@ class Explorer {
411
417
  if (this.xhrCapture && this.playwrightHelper?.page) {
412
418
  this.xhrCapture.detach(this.playwrightHelper.page);
413
419
  }
420
+ await this.playwrightRecorder.stop();
414
421
  if (this.options?.session && this.playwrightHelper?.browserContext) {
415
422
  const dir = path.dirname(this.options.session);
416
423
  if (!existsSync(dir))
@@ -605,6 +612,7 @@ function toCodeceptjsTest(test) {
605
612
  codeceptjsTest.fullTitle = () => `${parent.title} ${test.scenario}`;
606
613
  codeceptjsTest.state = 'pending';
607
614
  codeceptjsTest.notes = test.getPrintableNotes();
615
+ codeceptjsTest._explorbotTest = test;
608
616
  return codeceptjsTest;
609
617
  }
610
618
  const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
@@ -622,7 +630,7 @@ function parseAriaRefs(ariaSnapshot) {
622
630
  return entries;
623
631
  }
624
632
  export async function annotatePageElements(page) {
625
- const ariaSnapshot = await page.locator('body').ariaSnapshot({ forAI: true });
633
+ const ariaSnapshot = await page.locator('body').ariaSnapshot({ mode: 'ai' });
626
634
  const refEntries = parseAriaRefs(ariaSnapshot);
627
635
  const byRole = new Map();
628
636
  for (const { role, name, ref } of refEntries) {