explorbot 0.1.10 → 0.1.11

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 (84) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +27 -18
  3. package/dist/bin/explorbot-cli.js +26 -18
  4. package/dist/package.json +2 -2
  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 +46 -38
  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 +320 -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/utils.js +18 -0
  16. package/dist/src/ai/historian.js +19 -405
  17. package/dist/src/ai/navigator.js +82 -29
  18. package/dist/src/ai/pilot.js +232 -13
  19. package/dist/src/ai/planner.js +29 -9
  20. package/dist/src/ai/provider.js +54 -17
  21. package/dist/src/ai/researcher.js +41 -32
  22. package/dist/src/ai/rules.js +26 -14
  23. package/dist/src/ai/tester.js +90 -26
  24. package/dist/src/ai/tools.js +13 -7
  25. package/dist/src/browser-server.js +16 -3
  26. package/dist/src/commands/add-rule-command.js +11 -8
  27. package/dist/src/commands/clean-command.js +2 -1
  28. package/dist/src/commands/explore-command.js +27 -15
  29. package/dist/src/commands/init-command.js +9 -8
  30. package/dist/src/commands/plan-command.js +32 -0
  31. package/dist/src/commands/plan-save-command.js +19 -7
  32. package/dist/src/commands/rerun-command.js +4 -0
  33. package/dist/src/components/App.js +15 -5
  34. package/dist/src/execution-controller.js +13 -2
  35. package/dist/src/experience-tracker.js +20 -64
  36. package/dist/src/explorbot.js +5 -8
  37. package/dist/src/explorer.js +9 -2
  38. package/dist/src/observability.js +50 -99
  39. package/dist/src/playwright-recorder.js +309 -0
  40. package/dist/src/test-plan.js +12 -0
  41. package/dist/src/utils/aria.js +37 -1
  42. package/dist/src/utils/error-page.js +20 -7
  43. package/dist/src/utils/next-steps.js +37 -0
  44. package/package.json +2 -2
  45. package/rules/navigator/output.md +9 -0
  46. package/rules/navigator/verification-actions.md +2 -0
  47. package/src/action-result.ts +26 -1
  48. package/src/action.ts +44 -37
  49. package/src/ai/bosun.ts +11 -1
  50. package/src/ai/conversation.ts +37 -0
  51. package/src/ai/historian/codeceptjs.ts +130 -0
  52. package/src/ai/historian/experience.ts +383 -0
  53. package/src/ai/historian/mixin.ts +4 -0
  54. package/src/ai/historian/playwright.ts +169 -0
  55. package/src/ai/historian/utils.ts +23 -0
  56. package/src/ai/historian.ts +35 -473
  57. package/src/ai/navigator.ts +82 -29
  58. package/src/ai/pilot.ts +237 -14
  59. package/src/ai/planner.ts +29 -9
  60. package/src/ai/provider.ts +51 -17
  61. package/src/ai/researcher.ts +45 -33
  62. package/src/ai/rules.ts +27 -14
  63. package/src/ai/tester.ts +94 -26
  64. package/src/ai/tools.ts +47 -25
  65. package/src/browser-server.ts +17 -3
  66. package/src/commands/add-rule-command.ts +11 -7
  67. package/src/commands/clean-command.ts +2 -1
  68. package/src/commands/explore-command.ts +29 -15
  69. package/src/commands/init-command.ts +9 -8
  70. package/src/commands/plan-command.ts +35 -0
  71. package/src/commands/plan-save-command.ts +18 -7
  72. package/src/commands/rerun-command.ts +5 -0
  73. package/src/components/App.tsx +16 -5
  74. package/src/config.ts +6 -1
  75. package/src/execution-controller.ts +14 -3
  76. package/src/experience-tracker.ts +21 -72
  77. package/src/explorbot.ts +5 -8
  78. package/src/explorer.ts +11 -2
  79. package/src/observability.ts +50 -109
  80. package/src/playwright-recorder.ts +305 -0
  81. package/src/test-plan.ts +12 -0
  82. package/src/utils/aria.ts +38 -1
  83. package/src/utils/error-page.ts +22 -7
  84. package/src/utils/next-steps.ts +51 -0
@@ -2,7 +2,9 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
2
2
  import path from 'node:path';
3
3
  import { chromium, firefox, webkit } from 'playwright-core';
4
4
  import { ConfigParser } from './config.js';
5
- import { log, tag } from './utils/logger.js';
5
+ import { getCliName } from './utils/cli-name.ts';
6
+ import { log } from './utils/logger.js';
7
+ import { type NextStepSection, printNextSteps } from './utils/next-steps.ts';
6
8
 
7
9
  const ENDPOINT_FILENAME = '.browser-endpoint';
8
10
 
@@ -57,8 +59,20 @@ async function launchServer(opts: { browser?: string; show?: boolean }): Promise
57
59
  writeEndpoint(wsEndpoint);
58
60
 
59
61
  log(`Browser server started: ${browserName} (${opts.show ? 'headed' : 'headless'})`);
60
- tag('info').log(`WebSocket endpoint: ${wsEndpoint}`);
61
- tag('info').log(`Endpoint saved to: ${getEndpointFilePath()}`);
62
+
63
+ const cli = getCliName();
64
+ const sections: NextStepSection[] = [
65
+ {
66
+ label: 'Browser server',
67
+ path: getEndpointFilePath(),
68
+ commands: [
69
+ { label: 'Endpoint', command: wsEndpoint },
70
+ { label: 'Status', command: `${cli} browser status` },
71
+ { label: 'Stop', command: `${cli} browser stop` },
72
+ ],
73
+ },
74
+ ];
75
+ printNextSteps(sections);
62
76
 
63
77
  return server;
64
78
  }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { render } from 'ink';
4
4
  import React from 'react';
5
5
  import { tag } from '../utils/logger.js';
6
+ import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
6
7
  import { BaseCommand, type Suggestion } from './base-command.js';
7
8
 
8
9
  export class AddRuleCommand extends BaseCommand {
@@ -43,19 +44,22 @@ export class AddRuleCommand extends BaseCommand {
43
44
 
44
45
  const filePath = join(rulesDir, `${ruleName}.md`);
45
46
  if (existsSync(filePath)) {
46
- tag('warning').log(`Rule file already exists: ${filePath}`);
47
+ tag('warning').log(`Rule file already exists: ${relativeToCwd(filePath)}`);
47
48
  return null;
48
49
  }
49
50
 
50
51
  const content = opts?.content || `Instructions for ${agentName} agent.`;
51
52
  writeFileSync(filePath, `${content.trim()}\n`);
52
- tag('success').log(`Rule created: ${filePath}`);
53
53
 
54
- if (opts?.urlPattern) {
55
- tag('info').log(`Add to config: ai.agents.${agentName}.rules: [{ '${opts.urlPattern}': '${ruleName}' }]`);
56
- } else {
57
- tag('info').log(`Add to config: ai.agents.${agentName}.rules: ['${ruleName}']`);
58
- }
54
+ const configLine = opts?.urlPattern ? `ai.agents.${agentName}.rules: [{ '${opts.urlPattern}': '${ruleName}' }]` : `ai.agents.${agentName}.rules: ['${ruleName}']`;
55
+ const sections: NextStepSection[] = [
56
+ {
57
+ label: 'Rule',
58
+ path: filePath,
59
+ commands: [{ label: 'Add to config', command: configLine }],
60
+ },
61
+ ];
62
+ printNextSteps(sections);
59
63
 
60
64
  return filePath;
61
65
  }
@@ -8,6 +8,7 @@ export const CLEAN_TARGETS: Record<string, { description: string; getDir: () =>
8
8
  states: { description: 'page states', getDir: () => outputPath('states') },
9
9
  research: { description: 'research cache', getDir: () => outputPath('research') },
10
10
  plans: { description: 'test plans', getDir: () => outputPath('plans') },
11
+ tests: { description: 'generated tests', getDir: () => outputPath('tests') },
11
12
  experiences: { description: 'experience files', getDir: () => getExperienceDir() },
12
13
  output: { description: 'all output files', getDir: () => outputPath() },
13
14
  };
@@ -40,7 +41,7 @@ function cleanDirectoryContents(dirPath: string): number {
40
41
 
41
42
  export class CleanCommand extends BaseCommand {
42
43
  name = 'clean';
43
- description = 'Clean files: clean [states|research|plans|experiences|output]';
44
+ description = 'Clean files: clean [states|research|plans|tests|experiences|output]';
44
45
  suggestions: Suggestion[] = Object.entries(CLEAN_TARGETS).map(([name, target]) => ({ command: `clean ${name}`, hint: `clean ${target.description}` }));
45
46
 
46
47
  async execute(args: string): Promise<void> {
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import figureSet from 'figures';
3
2
  import { getStyles } from '../ai/planner/styles.js';
4
3
  import { Stats } from '../stats.js';
@@ -7,6 +6,7 @@ import { getCliName } from '../utils/cli-name.ts';
7
6
  import { ErrorPageError } from '../utils/error-page.ts';
8
7
  import { tag } from '../utils/logger.js';
9
8
  import { jsonToTable } from '../utils/markdown-parser.js';
9
+ import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
10
10
  import { BaseCommand, type Suggestion } from './base-command.js';
11
11
 
12
12
  export class ExploreCommand extends BaseCommand {
@@ -70,8 +70,8 @@ export class ExploreCommand extends BaseCommand {
70
70
  this.explorBot.setCurrentPlan(mainPlan);
71
71
  if (mainUrl) await this.explorBot.visit(mainUrl);
72
72
  const savedPath = this.explorBot.savePlans(this.completedPlans);
73
- this.printResults(savedPath);
74
- this.printRerunSuggestions();
73
+ this.printResults();
74
+ this.printNextSteps(savedPath);
75
75
  }
76
76
 
77
77
  private async runAllStyles(pageUrl?: string, feature?: string, parentPlan?: Plan, completedPlans?: Plan[]): Promise<void> {
@@ -103,7 +103,7 @@ export class ExploreCommand extends BaseCommand {
103
103
  }
104
104
  }
105
105
 
106
- private printResults(savedPath?: string | null): void {
106
+ private printResults(): void {
107
107
  const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title })));
108
108
 
109
109
  if (allTests.length === 0) return;
@@ -132,22 +132,36 @@ export class ExploreCommand extends BaseCommand {
132
132
  if (hasSubPages) columns.push('Plan');
133
133
  tag('multiline').log(jsonToTable(rows, columns));
134
134
  tag('info').log(`${figureSet.tick} ${allTests.length} tests completed`);
135
+ }
135
136
 
136
- if (savedPath) {
137
- const relativePath = path.relative(process.cwd(), savedPath);
138
- tag('info').log(`Re-run tests: ${getCliName()} test ${relativePath} <index>`);
137
+ private printNextSteps(savedPlanPath?: string | null): void {
138
+ const cli = getCliName();
139
+ const sections: NextStepSection[] = [];
140
+
141
+ if (savedPlanPath) {
142
+ const relPlan = relativeToCwd(savedPlanPath);
143
+ sections.push({
144
+ label: 'Plan',
145
+ path: savedPlanPath,
146
+ commands: [
147
+ { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
148
+ { label: 'Run all', command: `${cli} test ${relPlan} *` },
149
+ { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
150
+ ],
151
+ });
139
152
  }
140
- }
141
153
 
142
- private printRerunSuggestions(): void {
143
154
  const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
144
- if (savedFiles.length === 0) return;
145
-
146
- for (const filePath of savedFiles) {
147
- tag('info').log(`Generated: ${path.basename(filePath)}`);
155
+ if (savedFiles.length > 0) {
156
+ const commands = savedFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
157
+ commands.push({ label: 'List tests', command: `${cli} runs` });
158
+ sections.push({
159
+ label: `Generated tests (${savedFiles.length})`,
160
+ commands,
161
+ });
148
162
  }
149
- tag('info').log(`List tests: ${getCliName()} runs`);
150
- tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
163
+
164
+ printNextSteps(sections);
151
165
  }
152
166
 
153
167
  private isLimitReached(): boolean {
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import dedent from 'dedent';
5
5
  import { getCliName } from '../utils/cli-name.ts';
6
6
  import { log, tag } from '../utils/logger.js';
7
+ import { relativeToCwd } from '../utils/next-steps.ts';
7
8
 
8
9
  const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
9
10
  // import { '<your provider here>' } from '<your provider package here>';
@@ -61,10 +62,10 @@ export function runInitCommand(options: InitCommandOptions): void {
61
62
  const dir = resolve(customPath);
62
63
  if (!existsSync(dir)) {
63
64
  mkdirSync(dir, { recursive: true });
64
- log(`Created directory: ${dir}`);
65
+ log(`Created directory: ${relativeToCwd(dir)}`);
65
66
  }
66
67
  process.chdir(dir);
67
- log(`Working in directory: ${dir}`);
68
+ log(`Working in directory: ${relativeToCwd(dir)}`);
68
69
  }
69
70
 
70
71
  try {
@@ -78,24 +79,24 @@ export function runInitCommand(options: InitCommandOptions): void {
78
79
  const dir = dirname(outPath);
79
80
  if (!existsSync(dir)) {
80
81
  mkdirSync(dir, { recursive: true });
81
- log(`Created directory: ${dir}`);
82
+ log(`Created directory: ${relativeToCwd(dir)}`);
82
83
  }
83
84
 
84
85
  if (existsSync(outPath) && !force) {
85
- log(`Config file already exists: ${outPath}`);
86
+ log(`Config file already exists: ${relativeToCwd(outPath)}`);
86
87
  log('Use --force to overwrite existing file');
87
88
  process.exit(1);
88
89
  }
89
90
 
90
91
  writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
91
- log(`Created config file: ${outPath}`);
92
+ log(`Created config file: ${relativeToCwd(outPath)}`);
92
93
 
93
94
  const envPath = resolve(process.cwd(), '.env');
94
95
  if (!existsSync(envPath)) {
95
96
  writeFileSync(envPath, `${DEFAULT_ENV_TEMPLATE}\n`, 'utf8');
96
- log(`Created env file: ${envPath}`);
97
+ log(`Created env file: ${relativeToCwd(envPath)}`);
97
98
  } else {
98
- log(`Env file already exists: ${envPath}`);
99
+ log(`Env file already exists: ${relativeToCwd(envPath)}`);
99
100
  }
100
101
 
101
102
  log('');
@@ -112,7 +113,7 @@ export function runInitCommand(options: InitCommandOptions): void {
112
113
 
113
114
  if (!existsSync('./output')) {
114
115
  mkdirSync('./output', { recursive: true });
115
- log('Created directory: ./output');
116
+ log('Created directory: output');
116
117
  }
117
118
  } catch (error) {
118
119
  log('Failed to create config file:', 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.ts';
4
5
  import { tag } from '../utils/logger.js';
6
+ import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
5
7
  import { BaseCommand, type Suggestion } from './base-command.js';
6
8
 
7
9
  export class PlanCommand extends BaseCommand {
@@ -41,6 +43,39 @@ export class PlanCommand extends BaseCommand {
41
43
 
42
44
  this.printPlanSummary();
43
45
  this.updateSuggestions();
46
+ this.printNextSteps();
47
+ }
48
+
49
+ private printNextSteps(): void {
50
+ const savedPath = this.explorBot.lastSavedPlanPath;
51
+ if (!savedPath) return;
52
+
53
+ const cli = getCliName();
54
+ const relPlan = relativeToCwd(savedPath);
55
+ const sections: NextStepSection[] = [
56
+ {
57
+ label: 'Plan',
58
+ path: savedPath,
59
+ commands: [
60
+ { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
61
+ { label: 'Run all', command: `${cli} test ${relPlan} *` },
62
+ { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
63
+ { label: 'Reload', command: `/plan:load ${relPlan}` },
64
+ ],
65
+ },
66
+ ];
67
+
68
+ const suite = this.explorBot.getSuite();
69
+ const files = suite && suite.automatedTestCount > 0 ? suite.getAutomatedTestFiles() : [];
70
+ if (files.length > 0) {
71
+ const commands = files.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
72
+ sections.push({
73
+ label: `Automated tests (${files.length})`,
74
+ commands,
75
+ });
76
+ }
77
+
78
+ printNextSteps(sections);
44
79
  }
45
80
 
46
81
  private printPlanSummary(): void {
@@ -1,5 +1,5 @@
1
- import path from 'node:path';
2
- import { tag } from '../utils/logger.js';
1
+ import { getCliName } from '../utils/cli-name.ts';
2
+ import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
3
3
  import { BaseCommand, type Suggestion } from './base-command.js';
4
4
 
5
5
  export class PlanSaveCommand extends BaseCommand {
@@ -15,11 +15,22 @@ export class PlanSaveCommand extends BaseCommand {
15
15
 
16
16
  const filename = args.trim() || undefined;
17
17
  const savedPath = this.explorBot.savePlan(filename);
18
+ if (!savedPath) return;
18
19
 
19
- if (savedPath) {
20
- const relativePath = path.relative(process.cwd(), savedPath);
21
- tag('success').log(`Plan saved to: ${relativePath}`);
22
- tag('info').log(`Run /plan:load ${relativePath} to reload it`);
23
- }
20
+ const cli = getCliName();
21
+ const relPlan = relativeToCwd(savedPath);
22
+ const sections: NextStepSection[] = [
23
+ {
24
+ label: 'Plan',
25
+ path: savedPath,
26
+ commands: [
27
+ { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
28
+ { label: 'Run all', command: `${cli} test ${relPlan} *` },
29
+ { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
30
+ { label: 'Reload', command: `/plan:load ${relPlan}` },
31
+ ],
32
+ },
33
+ ];
34
+ printNextSteps(sections);
24
35
  }
25
36
  }
@@ -24,6 +24,11 @@ export class RerunCommand extends BaseCommand {
24
24
  filePath = resolve(ConfigParser.getInstance().getTestsDir(), filename);
25
25
  }
26
26
 
27
+ if (filePath.endsWith('.spec.ts') || filePath.endsWith('.spec.js')) {
28
+ tag('error').log(`Rerun does not support Playwright tests. Run them with: npx playwright test ${filePath}`);
29
+ return;
30
+ }
31
+
27
32
  const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
28
33
  await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
29
34
  }
@@ -99,7 +99,11 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
99
99
  setShowInput(true);
100
100
 
101
101
  return new Promise<string | null>((resolve) => {
102
- interruptResolveRef.current = resolve;
102
+ interruptResolveRef.current = (value) => {
103
+ interruptResolveRef.current = null;
104
+ setInterruptPrompt(null);
105
+ resolve(value);
106
+ };
103
107
  });
104
108
  });
105
109
 
@@ -107,11 +111,19 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
107
111
  setShowInput(true);
108
112
  };
109
113
 
114
+ const handleInterrupt = () => {
115
+ if (interruptResolveRef.current) {
116
+ interruptResolveRef.current(null);
117
+ }
118
+ };
119
+
110
120
  executionController.on('idle', handleIdle);
121
+ executionController.on('interrupt', handleInterrupt);
111
122
  setInputCallbackReady(true);
112
123
 
113
124
  return () => {
114
125
  executionController.off('idle', handleIdle);
126
+ executionController.off('interrupt', handleInterrupt);
115
127
  executionController.reset();
116
128
  };
117
129
  }, []);
@@ -284,9 +296,10 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
284
296
  }
285
297
 
286
298
  if (isCommand) {
287
- setInterruptPrompt(null);
299
+ if (interruptResolveRef.current) {
300
+ interruptResolveRef.current(null);
301
+ }
288
302
  setShowInput(false);
289
- interruptResolveRef.current = null;
290
303
  executionController.startExecution();
291
304
  try {
292
305
  await commandHandler.executeCommand(trimmed);
@@ -303,8 +316,6 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
303
316
 
304
317
  if (interruptResolveRef.current) {
305
318
  interruptResolveRef.current(input);
306
- interruptResolveRef.current = null;
307
- setInterruptPrompt(null);
308
319
  setShowInput(false);
309
320
  return;
310
321
  }
package/src/config.ts CHANGED
@@ -107,6 +107,10 @@ interface PlannerAgentConfig extends AgentConfig {
107
107
  stylesDir?: string;
108
108
  }
109
109
 
110
+ interface HistorianAgentConfig extends AgentConfig {
111
+ framework?: 'codeceptjs' | 'playwright';
112
+ }
113
+
110
114
  interface AgentsConfig {
111
115
  tester?: TesterAgentConfig;
112
116
  navigator?: NavigatorAgentConfig;
@@ -116,7 +120,7 @@ interface AgentsConfig {
116
120
  'experience-compactor'?: AgentConfig;
117
121
  captain?: AgentConfig;
118
122
  quartermaster?: AgentConfig;
119
- historian?: AgentConfig;
123
+ historian?: HistorianAgentConfig;
120
124
  fisherman?: AgentConfig;
121
125
  chief?: AgentConfig;
122
126
  curler?: AgentConfig;
@@ -229,6 +233,7 @@ export type {
229
233
  ActionConfig,
230
234
  AgentConfig,
231
235
  AgentsConfig,
236
+ HistorianAgentConfig,
232
237
  ResearcherAgentConfig,
233
238
  NavigatorAgentConfig,
234
239
  PlannerAgentConfig,
@@ -10,6 +10,7 @@ export class ExecutionController extends EventEmitter {
10
10
  private inputCallback: InputCallback | null = null;
11
11
  private interruptResolvers: Array<() => void> = [];
12
12
  private abortController: AbortController | null = null;
13
+ private awaitingInput = false;
13
14
 
14
15
  private constructor() {
15
16
  super();
@@ -48,6 +49,10 @@ export class ExecutionController extends EventEmitter {
48
49
  this.emit('idle');
49
50
  }
50
51
 
52
+ isAwaitingInput(): boolean {
53
+ return this.awaitingInput;
54
+ }
55
+
51
56
  isInterrupted(): boolean {
52
57
  return this.interrupted;
53
58
  }
@@ -77,11 +82,16 @@ export class ExecutionController extends EventEmitter {
77
82
  }
78
83
 
79
84
  async requestInput(prompt: string): Promise<string | null> {
80
- if (this.inputCallback) {
81
- return await this.inputCallback(prompt);
85
+ if (!this.inputCallback) {
86
+ return await this.readlineInput(prompt);
82
87
  }
83
88
 
84
- return await this.readlineInput(prompt);
89
+ this.awaitingInput = true;
90
+ try {
91
+ return await this.inputCallback(prompt);
92
+ } finally {
93
+ this.awaitingInput = false;
94
+ }
85
95
  }
86
96
 
87
97
  private async readlineInput(prompt: string): Promise<string | null> {
@@ -103,6 +113,7 @@ export class ExecutionController extends EventEmitter {
103
113
  this.interrupted = false;
104
114
  this.interruptResolvers = [];
105
115
  this.abortController = null;
116
+ this.awaitingInput = false;
106
117
  }
107
118
  }
108
119
 
@@ -18,15 +18,17 @@ export const RECENT_WINDOW_DAYS = 30;
18
18
  /**
19
19
  * Stores and reads per-page experience files (`./experience/<stateHash>.md`).
20
20
  *
21
- * Format rules (enforced by writeFlow/writeAction — the only supported writers):
21
+ * Two writers, two contracts:
22
22
  *
23
- * ## FLOW: <imperative title> multi-step, `*` bullets + optional ```js``` + `>` discovery, ends with `---`
24
- * ## ACTION: <imperative title> single-step, optional `Solution:` line + one ```js``` code block
23
+ * writeFlow(state, body, relatedUrls?) — caller hands in a fully-formatted
24
+ * `## FLOW: <imperative title>` block (multi-step,
25
+ * `*` bullets + optional ```js``` + `>` discovery,
26
+ * ends with `---`). Tracker dedups + prepends.
27
+ * writeAction(state, ActionInput) — `## ACTION: <imperative title>`, single-step,
28
+ * optional `Solution:` line + one ```js``` code block.
29
+ * Title normalized via normalizeTitle().
25
30
  *
26
31
  * - Always h2. Never h3 for FLOW/ACTION.
27
- * - Title is an imperative verb phrase, lowercase-first, no trailing punctuation.
28
- * Writers normalize automatically (strip own `FLOW:` / `ACTION:` prefix if the caller included it,
29
- * lowercase first char, trim trailing `.!?,;:`).
30
32
  * - On read (getSuccessfulExperience), headings are rendered as
31
33
  * `## HOW to <title> (multi-step|single-step)` so prompts get natural phrasing.
32
34
  */
@@ -184,27 +186,27 @@ export class ExperienceTracker {
184
186
  tag('substep').log(` Added ACTION to: ${stateHash}.md`);
185
187
  }
186
188
 
187
- writeFlow(state: ActionResult, flow: FlowInput): void {
189
+ writeFlow(state: ActionResult, body: string, relatedUrls?: string[]): void {
188
190
  if (this.disabled || this.isWritingDisabled(state)) return;
189
- if (!flow.steps?.length) return;
191
+ if (!body?.trim()) return;
190
192
 
191
193
  this.ensureExperienceFile(state);
192
194
  const stateHash = state.getStateHash();
193
195
  const { content, data } = this.readExperienceFile(stateHash);
194
196
 
195
- if (flow.relatedUrls?.length) {
197
+ if (content.includes(body)) {
198
+ debugLog('Skipping duplicate flow body');
199
+ return;
200
+ }
201
+
202
+ if (relatedUrls?.length) {
196
203
  const currentPath = extractStatePath(state.url || '');
197
204
  const existingRelated = Array.isArray(data.related) ? data.related : [];
198
- const allRelated = [...new Set([...existingRelated, ...flow.relatedUrls])];
205
+ const allRelated = [...new Set([...existingRelated, ...relatedUrls])];
199
206
  data.related = allRelated.filter((url) => url !== currentPath);
200
207
  }
201
208
 
202
- const title = normalizeTitle(flow.scenario);
203
- if (!title) return;
204
-
205
- const sessionContent = this.trimSessionContent(generateFlowContent(title, flow.steps));
206
- if (!sessionContent) return;
207
- const updatedContent = `${sessionContent}\n${content}`;
209
+ const updatedContent = `${body}\n${content}`;
208
210
  this.writeExperienceFile(stateHash, updatedContent, data);
209
211
 
210
212
  tag('substep').log(`Added FLOW to: ${stateHash}.md`);
@@ -277,35 +279,6 @@ export class ExperienceTracker {
277
279
  // The actual files will be cleaned up by test cleanup
278
280
  }
279
281
 
280
- private trimSessionContent(content: string): string | null {
281
- const q = mdq(content);
282
- if (q.query('heading').count() === 0) return null;
283
- if (q.query('code').count() === 0) return null;
284
-
285
- let result = content;
286
- const codeBlocks = q.query('code').each();
287
- if (codeBlocks.length > 2) {
288
- for (const block of codeBlocks.slice(2)) {
289
- result = result.replace(block.text(), '');
290
- }
291
- }
292
-
293
- const blockquotes = mdq(result).query('blockquote').each();
294
- if (blockquotes.length > 5) {
295
- for (const bq of blockquotes.slice(5)) {
296
- result = result.replace(bq.text(), '');
297
- }
298
- }
299
-
300
- const lines = result.split('\n');
301
- if (lines.length > 40) {
302
- result = lines.slice(0, 40).join('\n');
303
- }
304
-
305
- if (!result.trim()) return null;
306
- return result;
307
- }
308
-
309
282
  getSuccessfulExperience(state: ActionResult, options?: { includeDescendants?: boolean; stripCode?: boolean }): string[] {
310
283
  const records = this.getRelevantExperience(state, {
311
284
  includeDescendantExperience: options?.includeDescendants,
@@ -513,7 +486,9 @@ export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
513
486
 
514
487
  const lines: string[] = [];
515
488
  lines.push('<experience>');
516
- lines.push('Past experience for this page — reusable recipes recorded from prior successful runs.');
489
+ lines.push('Past experience for this page — recipes recorded from prior successful runs.');
490
+ lines.push('Locators and step ordering worked then; the page may have changed since.');
491
+ lines.push('Treat as a starting hypothesis, not ground truth. If a step fails, fall back to ARIA/UI-map.');
517
492
  lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
518
493
  lines.push('Call learn_experience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
519
494
  lines.push('');
@@ -559,26 +534,6 @@ function generateActionContent(title: string, code: string, explanation?: string
559
534
  return lines.join('\n');
560
535
  }
561
536
 
562
- function generateFlowContent(title: string, steps: SessionStep[]): string {
563
- let content = `## FLOW: ${title}\n\n`;
564
- for (const step of steps) {
565
- content += `* ${step.message}\n\n`;
566
- if (step.code) {
567
- content += '```js\n';
568
- content += `${step.code}\n`;
569
- content += '```\n\n';
570
- }
571
- if (step.discovery) {
572
- const discoveries = step.discovery.split('\n').filter((d) => d.trim());
573
- for (const discovery of discoveries) {
574
- content += `> ${discovery.trim()}\n\n`;
575
- }
576
- }
577
- }
578
- content += '---\n';
579
- return content;
580
- }
581
-
582
537
  function renderAsHowTo(content: string): string {
583
538
  const tokens = marked.lexer(content);
584
539
  let result = '';
@@ -606,12 +561,6 @@ export interface ExperienceFile {
606
561
  mtime: Date;
607
562
  }
608
563
 
609
- export interface FlowInput {
610
- scenario: string;
611
- steps: SessionStep[];
612
- relatedUrls?: string[];
613
- }
614
-
615
564
  export interface ActionInput {
616
565
  title: string;
617
566
  code: string;
package/src/explorbot.ts CHANGED
@@ -53,6 +53,7 @@ export class ExplorBot {
53
53
  private currentPlan?: Plan;
54
54
  private planFeature?: string;
55
55
  lastPlanError: Error | null = null;
56
+ lastSavedPlanPath: string | null = null;
56
57
  private agents: Record<string, any> = {};
57
58
 
58
59
  constructor(options: ExplorBotOptions = {}) {
@@ -258,10 +259,10 @@ export class ExplorBot {
258
259
  }
259
260
 
260
261
  agentHistorian(): Historian {
261
- return (this.agents.historian ||= this.createAgent(({ ai, explorer }) => {
262
+ return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
262
263
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
263
264
  const reporter = explorer.getReporter();
264
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager());
265
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, explorer.getPlaywrightRecorder());
265
266
  }));
266
267
  }
267
268
 
@@ -369,12 +370,7 @@ export class ExplorBot {
369
370
  return this.currentPlan;
370
371
  }
371
372
 
372
- const savedPath = this.savePlan();
373
- if (savedPath) {
374
- const relativePath = path.relative(process.cwd(), savedPath);
375
- tag('info').log(`Plan saved to: ${relativePath}`);
376
- tag('info').log(`Edit the plan file and run /plan:load ${relativePath} to reload it`);
377
- }
373
+ this.savePlan();
378
374
 
379
375
  return this.currentPlan;
380
376
  }
@@ -400,6 +396,7 @@ export class ExplorBot {
400
396
  const planFilename = filename || this.generatePlanFilename();
401
397
  const planPath = path.join(plansDir, planFilename);
402
398
  Plan.saveMultipleToMarkdown(plans, planPath);
399
+ this.lastSavedPlanPath = planPath;
403
400
  return planPath;
404
401
  }
405
402