explorbot 0.1.9 → 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 (157) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +86 -15
  3. package/boat/api-tester/src/ai/curler-tools.ts +3 -3
  4. package/boat/api-tester/src/ai/curler.ts +1 -1
  5. package/boat/api-tester/src/apibot.ts +2 -2
  6. package/boat/api-tester/src/config.ts +1 -1
  7. package/dist/bin/explorbot-cli.js +85 -14
  8. package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
  9. package/dist/boat/api-tester/src/apibot.js +2 -2
  10. package/dist/package.json +2 -2
  11. package/dist/rules/navigator/output.md +9 -0
  12. package/dist/rules/navigator/verification-actions.md +2 -0
  13. package/dist/src/action-result.js +23 -1
  14. package/dist/src/action.js +46 -38
  15. package/dist/src/ai/bosun.js +16 -2
  16. package/dist/src/ai/conversation.js +39 -0
  17. package/dist/src/ai/experience-compactor.js +235 -50
  18. package/dist/src/ai/historian/codeceptjs.js +109 -0
  19. package/dist/src/ai/historian/experience.js +320 -0
  20. package/dist/src/ai/historian/mixin.js +2 -0
  21. package/dist/src/ai/historian/playwright.js +145 -0
  22. package/dist/src/ai/historian/utils.js +18 -0
  23. package/dist/src/ai/historian.js +19 -398
  24. package/dist/src/ai/navigator.js +133 -80
  25. package/dist/src/ai/pilot.js +254 -13
  26. package/dist/src/ai/planner/subpages.js +1 -30
  27. package/dist/src/ai/planner.js +33 -13
  28. package/dist/src/ai/provider.js +55 -18
  29. package/dist/src/ai/rerunner.js +3 -3
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  31. package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
  32. package/dist/src/ai/researcher/locators.js +1 -1
  33. package/dist/src/ai/researcher/sections.js +8 -1
  34. package/dist/src/ai/researcher.js +43 -41
  35. package/dist/src/ai/rules.js +26 -14
  36. package/dist/src/ai/tester.js +90 -26
  37. package/dist/src/ai/tools.js +18 -10
  38. package/dist/src/api/request-store.js +20 -0
  39. package/dist/src/api/xhr-capture.js +19 -3
  40. package/dist/src/browser-server.js +16 -3
  41. package/dist/src/command-handler.js +1 -1
  42. package/dist/src/commands/add-rule-command.js +12 -9
  43. package/dist/src/commands/base-command.js +20 -0
  44. package/dist/src/commands/clean-command.js +3 -2
  45. package/dist/src/commands/compact-command.js +138 -0
  46. package/dist/src/commands/context-command.js +7 -1
  47. package/dist/src/commands/drill-command.js +4 -1
  48. package/dist/src/commands/experience-command.js +104 -0
  49. package/dist/src/commands/explore-command.js +54 -19
  50. package/dist/src/commands/freesail-command.js +2 -0
  51. package/dist/src/commands/index.js +7 -3
  52. package/dist/src/commands/init-command.js +11 -10
  53. package/dist/src/commands/learn-command.js +1 -1
  54. package/dist/src/commands/navigate-command.js +4 -1
  55. package/dist/src/commands/plan-clear-command.js +4 -1
  56. package/dist/src/commands/plan-command.js +43 -4
  57. package/dist/src/commands/plan-edit-command.js +1 -1
  58. package/dist/src/commands/plan-load-command.js +4 -1
  59. package/dist/src/commands/plan-reload-command.js +4 -1
  60. package/dist/src/commands/plan-save-command.js +20 -8
  61. package/dist/src/commands/rerun-command.js +4 -0
  62. package/dist/src/commands/research-command.js +5 -2
  63. package/dist/src/commands/start-command.js +5 -1
  64. package/dist/src/commands/test-command.js +7 -1
  65. package/dist/src/components/App.js +15 -5
  66. package/dist/src/execution-controller.js +13 -2
  67. package/dist/src/experience-tracker.js +174 -83
  68. package/dist/src/explorbot.js +31 -22
  69. package/dist/src/explorer.js +12 -5
  70. package/dist/src/observability.js +50 -99
  71. package/dist/src/playwright-recorder.js +309 -0
  72. package/dist/src/reporter.js +17 -2
  73. package/dist/src/stats.js +2 -0
  74. package/dist/src/suite.js +1 -1
  75. package/dist/src/test-plan.js +12 -0
  76. package/dist/src/utils/aria.js +37 -1
  77. package/dist/src/utils/error-page.js +30 -7
  78. package/dist/src/utils/logger.js +1 -1
  79. package/dist/src/utils/next-steps.js +37 -0
  80. package/dist/src/utils/rules-loader.js +1 -1
  81. package/dist/src/utils/test-files.js +1 -1
  82. package/dist/src/utils/url-matcher.js +50 -0
  83. package/package.json +2 -2
  84. package/rules/navigator/output.md +9 -0
  85. package/rules/navigator/verification-actions.md +2 -0
  86. package/src/action-result.ts +26 -1
  87. package/src/action.ts +44 -37
  88. package/src/ai/bosun.ts +16 -2
  89. package/src/ai/conversation.ts +37 -0
  90. package/src/ai/experience-compactor.ts +270 -63
  91. package/src/ai/historian/codeceptjs.ts +130 -0
  92. package/src/ai/historian/experience.ts +383 -0
  93. package/src/ai/historian/mixin.ts +4 -0
  94. package/src/ai/historian/playwright.ts +169 -0
  95. package/src/ai/historian/utils.ts +23 -0
  96. package/src/ai/historian.ts +35 -468
  97. package/src/ai/navigator.ts +140 -85
  98. package/src/ai/pilot.ts +259 -14
  99. package/src/ai/planner/subpages.ts +1 -24
  100. package/src/ai/planner.ts +34 -14
  101. package/src/ai/provider.ts +52 -18
  102. package/src/ai/rerunner.ts +3 -3
  103. package/src/ai/researcher/deep-analysis.ts +1 -1
  104. package/src/ai/researcher/fingerprint-worker.ts +1 -1
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/sections.ts +7 -1
  107. package/src/ai/researcher.ts +47 -42
  108. package/src/ai/rules.ts +27 -14
  109. package/src/ai/task-agent.ts +1 -1
  110. package/src/ai/tester.ts +94 -26
  111. package/src/ai/tools.ts +53 -29
  112. package/src/api/request-store.ts +22 -0
  113. package/src/api/xhr-capture.ts +21 -3
  114. package/src/browser-server.ts +17 -3
  115. package/src/command-handler.ts +1 -1
  116. package/src/commands/add-rule-command.ts +13 -9
  117. package/src/commands/base-command.ts +26 -1
  118. package/src/commands/clean-command.ts +4 -3
  119. package/src/commands/compact-command.ts +156 -0
  120. package/src/commands/context-command.ts +8 -2
  121. package/src/commands/drill-command.ts +5 -2
  122. package/src/commands/experience-command.ts +125 -0
  123. package/src/commands/explore-command.ts +58 -21
  124. package/src/commands/freesail-command.ts +2 -0
  125. package/src/commands/index.ts +7 -3
  126. package/src/commands/init-command.ts +11 -10
  127. package/src/commands/learn-command.ts +2 -2
  128. package/src/commands/navigate-command.ts +5 -2
  129. package/src/commands/plan-clear-command.ts +5 -2
  130. package/src/commands/plan-command.ts +47 -5
  131. package/src/commands/plan-edit-command.ts +2 -2
  132. package/src/commands/plan-load-command.ts +5 -2
  133. package/src/commands/plan-reload-command.ts +5 -2
  134. package/src/commands/plan-save-command.ts +20 -9
  135. package/src/commands/rerun-command.ts +5 -0
  136. package/src/commands/research-command.ts +6 -3
  137. package/src/commands/start-command.ts +6 -2
  138. package/src/commands/test-command.ts +8 -2
  139. package/src/components/App.tsx +16 -5
  140. package/src/config.ts +6 -1
  141. package/src/execution-controller.ts +14 -3
  142. package/src/experience-tracker.ts +198 -100
  143. package/src/explorbot.ts +33 -23
  144. package/src/explorer.ts +14 -5
  145. package/src/observability.ts +50 -109
  146. package/src/playwright-recorder.ts +305 -0
  147. package/src/reporter.ts +17 -3
  148. package/src/stats.ts +4 -0
  149. package/src/suite.ts +1 -1
  150. package/src/test-plan.ts +12 -0
  151. package/src/utils/aria.ts +38 -1
  152. package/src/utils/error-page.ts +32 -7
  153. package/src/utils/logger.ts +1 -1
  154. package/src/utils/next-steps.ts +51 -0
  155. package/src/utils/rules-loader.ts +1 -1
  156. package/src/utils/test-files.ts +1 -1
  157. package/src/utils/url-matcher.ts +43 -0
@@ -1,12 +1,18 @@
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';
8
10
  description = 'Plan testing for a feature';
9
- suggestions = ['/test - to launch first test', '/test * - to launch all tests', 'Edit the plan in file and call /plan:reload to update it'];
11
+ suggestions = [
12
+ { command: 'test', hint: 'launch first test' },
13
+ { command: 'test *', hint: 'launch all tests' },
14
+ { command: 'plan:reload', hint: 'after editing the plan file, reload it' },
15
+ ];
10
16
  options = [
11
17
  { flags: '--fresh', description: 'Regenerate plan from scratch' },
12
18
  { flags: '--clear', description: 'Clear plan before regenerating' },
@@ -30,6 +36,36 @@ export class PlanCommand extends BaseCommand {
30
36
  }
31
37
  this.printPlanSummary();
32
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);
33
69
  }
34
70
  printPlanSummary() {
35
71
  const suite = this.explorBot.getSuite();
@@ -50,13 +86,16 @@ export class PlanCommand extends BaseCommand {
50
86
  }
51
87
  }
52
88
  updateSuggestions() {
53
- this.suggestions = ['/test - to launch first test', '/test * - to launch all tests'];
89
+ this.suggestions = [
90
+ { command: 'test', hint: 'launch first test' },
91
+ { command: 'test *', hint: 'launch all tests' },
92
+ ];
54
93
  const suite = this.explorBot.getSuite();
55
94
  if (suite && suite.automatedTestCount > 0) {
56
95
  for (const f of suite.getAutomatedTestFiles()) {
57
- this.suggestions.push(`/rerun ${path.relative(process.cwd(), f)} - re-run automated tests`);
96
+ this.suggestions.push({ command: `rerun ${path.relative(process.cwd(), f)}`, hint: 're-run automated tests' });
58
97
  }
59
98
  }
60
- this.suggestions.push('Edit the plan in file and call /plan:reload to update it');
99
+ this.suggestions.push({ command: 'plan:reload', hint: 'after editing the plan file, reload it' });
61
100
  }
62
101
  }
@@ -2,6 +2,6 @@ import { BaseCommand } from './base-command.js';
2
2
  export class PlanEditCommand extends BaseCommand {
3
3
  name = 'plan:edit';
4
4
  description = 'Open test plan editor';
5
- suggestions = ['/plan:edit - toggle tests on/off'];
5
+ suggestions = [{ command: 'plan:edit', hint: 'toggle tests on/off' }];
6
6
  async execute(_args) { }
7
7
  }
@@ -3,7 +3,10 @@ import { BaseCommand } from './base-command.js';
3
3
  export class PlanLoadCommand extends BaseCommand {
4
4
  name = 'plan:load';
5
5
  description = 'Load plan from file';
6
- suggestions = ['/test - to launch first test', '/test * - to launch all tests'];
6
+ suggestions = [
7
+ { command: 'test', hint: 'launch first test' },
8
+ { command: 'test *', hint: 'launch all tests' },
9
+ ];
7
10
  async execute(args) {
8
11
  const filename = args.trim();
9
12
  if (!filename) {
@@ -3,7 +3,10 @@ import { BaseCommand } from './base-command.js';
3
3
  export class PlanReloadCommand extends BaseCommand {
4
4
  name = 'plan:reload';
5
5
  description = 'Clear current plan and regenerate';
6
- suggestions = ['/test - to launch first test', '/test * - to launch all tests'];
6
+ suggestions = [
7
+ { command: 'test', hint: 'launch first test' },
8
+ { command: 'test *', hint: 'launch all tests' },
9
+ ];
7
10
  async execute(args) {
8
11
  const currentPlan = this.explorBot.getCurrentPlan();
9
12
  if (!currentPlan) {
@@ -1,10 +1,10 @@
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';
6
6
  description = 'Save current plan to file';
7
- suggestions = ['/test - to launch first test'];
7
+ suggestions = [{ command: 'test', hint: 'launch first test' }];
8
8
  async execute(args) {
9
9
  const plan = this.explorBot.getCurrentPlan();
10
10
  if (!plan) {
@@ -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
  }
@@ -5,7 +5,10 @@ import { BaseCommand } from './base-command.js';
5
5
  export class ResearchCommand extends BaseCommand {
6
6
  name = 'research';
7
7
  description = 'Research current page or navigate to URI and research. Use --deep to explore interactive elements by clicking them. Use --data to include page data.';
8
- suggestions = ['/navigate <page> - to go to another page', '/plan <feature> - to plan testing'];
8
+ suggestions = [
9
+ { command: 'navigate <page>', hint: 'go to another page' },
10
+ { command: 'plan <feature>', hint: 'plan testing' },
11
+ ];
9
12
  options = [
10
13
  { flags: '--data', description: 'Include page data' },
11
14
  { flags: '--deep', description: 'Explore interactive elements by clicking them' },
@@ -37,7 +40,7 @@ export class ResearchCommand extends BaseCommand {
37
40
  tag('info').log(`Research file: ${join(outputDir, 'research', `${state.hash}.md`)}`);
38
41
  }
39
42
  if (!enableDeep) {
40
- this.suggestions = ['/research <page> --deep - analyze page for all expandable elements and interactions'];
43
+ this.suggestions = [{ command: 'research <page> --deep', hint: 'analyze page for all expandable elements and interactions' }];
41
44
  }
42
45
  }
43
46
  }
@@ -3,7 +3,11 @@ import { ExploreCommand } from './explore-command.js';
3
3
  export class StartCommand extends BaseCommand {
4
4
  name = 'start';
5
5
  description = 'Start web exploration';
6
- suggestions = ['/navigate <page> - to go to another page', '/research - to analyze', '/plan <feature> - to plan testing'];
6
+ suggestions = [
7
+ { command: 'navigate <page>', hint: 'go to another page' },
8
+ { command: 'research', hint: 'analyze current page' },
9
+ { command: 'plan <feature>', hint: 'plan testing' },
10
+ ];
7
11
  async execute(args) {
8
12
  await new ExploreCommand(this.explorBot).execute(args);
9
13
  }
@@ -1,12 +1,18 @@
1
+ import { Stats } from '../stats.js';
1
2
  import { Test } from '../test-plan.js';
2
3
  import { tag } from '../utils/logger.js';
3
4
  import { BaseCommand } from './base-command.js';
4
5
  export class TestCommand extends BaseCommand {
5
6
  name = 'test';
6
7
  description = 'Launch tester agent to execute test scenarios';
7
- suggestions = ['/test - to run next test', '/plan - to create new plan'];
8
+ suggestions = [
9
+ { command: 'test', hint: 'run next test' },
10
+ { command: 'plan', hint: 'create new plan' },
11
+ ];
8
12
  async execute(args) {
9
13
  const plan = this.explorBot.getCurrentPlan();
14
+ Stats.mode = 'test';
15
+ Stats.focus = plan?.title;
10
16
  const toExecute = [];
11
17
  const requirePlan = () => {
12
18
  if (!plan)
@@ -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();
@@ -9,6 +9,24 @@ import { mdq } from './utils/markdown-query.js';
9
9
  import { extractStatePath } from './utils/url-matcher.js';
10
10
  const debugLog = createDebug('explorbot:experience');
11
11
  const DEFAULT_MAX_EXPERIENCE_LINES = 100;
12
+ export const RECENT_WINDOW_DAYS = 30;
13
+ /**
14
+ * Stores and reads per-page experience files (`./experience/<stateHash>.md`).
15
+ *
16
+ * Two writers, two contracts:
17
+ *
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().
25
+ *
26
+ * - Always h2. Never h3 for FLOW/ACTION.
27
+ * - On read (getSuccessfulExperience), headings are rendered as
28
+ * `## HOW to <title> (multi-step|single-step)` so prompts get natural phrasing.
29
+ */
12
30
  export class ExperienceTracker {
13
31
  experienceDir;
14
32
  disabled;
@@ -122,28 +140,48 @@ export class ExperienceTracker {
122
140
  isWritingDisabled(state) {
123
141
  return this.knowledgeTracker.getRelevantKnowledge(state).some((k) => k.noExperienceWriting === true || k.noExperienceWriting === 'true');
124
142
  }
125
- async saveSuccessfulResolution(state, originalMessage, code, explanation) {
143
+ writeAction(state, action) {
126
144
  if (this.disabled || this.isWritingDisabled(state))
127
145
  return;
146
+ if (!action.code?.trim())
147
+ return;
148
+ this.ensureExperienceFile(state);
149
+ const stateHash = state.getStateHash();
150
+ const { content, data } = this.readExperienceFile(stateHash);
151
+ if (content.includes(action.code)) {
152
+ debugLog('Skipping duplicate action', action.code);
153
+ return;
154
+ }
155
+ const title = normalizeTitle(action.title.split('\n')[0]);
156
+ if (!title)
157
+ return;
158
+ const filteredCode = action.code.replace(/I\.amOnPage\s*\([^)]*\)/gs, '');
159
+ const newEntry = generateActionContent(title, filteredCode, action.explanation);
160
+ const updatedContent = `${newEntry}\n\n${content}`;
161
+ this.writeExperienceFile(stateHash, updatedContent, data);
162
+ tag('substep').log(` Added ACTION to: ${stateHash}.md`);
163
+ }
164
+ writeFlow(state, body, relatedUrls) {
165
+ if (this.disabled || this.isWritingDisabled(state))
166
+ return;
167
+ if (!body?.trim())
168
+ return;
128
169
  this.ensureExperienceFile(state);
129
170
  const stateHash = state.getStateHash();
130
171
  const { content, data } = this.readExperienceFile(stateHash);
131
- if (content.includes(code)) {
132
- debugLog('Skipping duplicate successful resolution', code);
172
+ if (content.includes(body)) {
173
+ debugLog('Skipping duplicate flow body');
133
174
  return;
134
175
  }
135
- const filteredCode = code.replace(/I\.amOnPage\s*\([^)]*\)/gs, '');
136
- const newEntryContent = `### SUCCEEDED: ${originalMessage.split('\n')[0]}
137
-
138
- ${explanation ? `Solution: ${explanation}` : ''}
139
-
140
- \`\`\`javascript
141
- ${filteredCode}
142
- \`\`\`
143
- `;
144
- const updatedContent = `${newEntryContent}\n\n${content}`;
176
+ if (relatedUrls?.length) {
177
+ const currentPath = extractStatePath(state.url || '');
178
+ const existingRelated = Array.isArray(data.related) ? data.related : [];
179
+ const allRelated = [...new Set([...existingRelated, ...relatedUrls])];
180
+ data.related = allRelated.filter((url) => url !== currentPath);
181
+ }
182
+ const updatedContent = `${body}\n${content}`;
145
183
  this.writeExperienceFile(stateHash, updatedContent, data);
146
- tag('substep').log(` Added successful resolution to: ${stateHash}.md`);
184
+ tag('substep').log(`Added FLOW to: ${stateHash}.md`);
147
185
  }
148
186
  getAllExperience() {
149
187
  const allFiles = [];
@@ -159,10 +197,12 @@ ${filteredCode}
159
197
  try {
160
198
  const content = readFileSync(file, 'utf8');
161
199
  const parsed = matter(content);
200
+ const mtime = statSync(file).mtime;
162
201
  allFiles.push({
163
202
  filePath: file,
164
203
  data: parsed.data,
165
204
  content: parsed.content,
205
+ mtime,
166
206
  });
167
207
  }
168
208
  catch (error) {
@@ -205,71 +245,6 @@ ${filteredCode}
205
245
  // Clear any in-memory state if needed
206
246
  // The actual files will be cleaned up by test cleanup
207
247
  }
208
- saveSessionExperience(state, entry) {
209
- if (this.disabled || this.isWritingDisabled(state))
210
- return;
211
- this.ensureExperienceFile(state);
212
- const stateHash = state.getStateHash();
213
- const { content, data } = this.readExperienceFile(stateHash);
214
- if (entry.relatedUrls?.length) {
215
- const currentPath = extractStatePath(state.url || '');
216
- const existingRelated = Array.isArray(data.related) ? data.related : [];
217
- const allRelated = [...new Set([...existingRelated, ...entry.relatedUrls])];
218
- data.related = allRelated.filter((url) => url !== currentPath);
219
- }
220
- const sessionContent = this.trimSessionContent(this.generateSessionContent(entry));
221
- if (!sessionContent)
222
- return;
223
- const updatedContent = `${sessionContent}\n${content}`;
224
- this.writeExperienceFile(stateHash, updatedContent, data);
225
- tag('substep').log(`Added session experience to: ${stateHash}.md`);
226
- }
227
- generateSessionContent(entry) {
228
- let content = `## Successful Flow: ${entry.scenario}\n\n`;
229
- for (const step of entry.steps) {
230
- content += `* ${step.message}\n\n`;
231
- if (step.code) {
232
- content += '```js\n';
233
- content += `${step.code}\n`;
234
- content += '```\n\n';
235
- }
236
- if (step.discovery) {
237
- const discoveries = step.discovery.split('\n').filter((d) => d.trim());
238
- for (const discovery of discoveries) {
239
- content += `> ${discovery.trim()}\n\n`;
240
- }
241
- }
242
- }
243
- content += '---\n';
244
- return content;
245
- }
246
- trimSessionContent(content) {
247
- const q = mdq(content);
248
- if (q.query('heading').count() === 0)
249
- return null;
250
- if (q.query('code').count() === 0)
251
- return null;
252
- let result = content;
253
- const codeBlocks = q.query('code').each();
254
- if (codeBlocks.length > 2) {
255
- for (const block of codeBlocks.slice(2)) {
256
- result = result.replace(block.text(), '');
257
- }
258
- }
259
- const blockquotes = mdq(result).query('blockquote').each();
260
- if (blockquotes.length > 5) {
261
- for (const bq of blockquotes.slice(5)) {
262
- result = result.replace(bq.text(), '');
263
- }
264
- }
265
- const lines = result.split('\n');
266
- if (lines.length > 40) {
267
- result = lines.slice(0, 40).join('\n');
268
- }
269
- if (!result.trim())
270
- return null;
271
- return result;
272
- }
273
248
  getSuccessfulExperience(state, options) {
274
249
  const records = this.getRelevantExperience(state, {
275
250
  includeDescendantExperience: options?.includeDescendants,
@@ -278,11 +253,12 @@ ${filteredCode}
278
253
  for (const record of records) {
279
254
  if (!record.content)
280
255
  continue;
281
- const successFlows = mdq(record.content).query('section(~"Successful Flow")').text();
282
- const succeeded = mdq(record.content).query('section(~"SUCCEEDED")').text();
283
- let combined = [successFlows, succeeded].filter(Boolean).join('\n\n');
256
+ const flows = mdq(record.content).query('section(~"FLOW:")').text();
257
+ const actions = mdq(record.content).query('section(~"ACTION:")').text();
258
+ let combined = [flows, actions].filter(Boolean).join('\n\n');
284
259
  if (!combined.trim())
285
260
  continue;
261
+ combined = renderAsHowTo(combined);
286
262
  if (options?.stripCode) {
287
263
  combined = mdq(combined).query('code').replace('');
288
264
  }
@@ -339,6 +315,69 @@ ${filteredCode}
339
315
  }
340
316
  return null;
341
317
  }
318
+ listAllExperienceToc(filter, options) {
319
+ const records = this.getAllExperience();
320
+ if (records.length === 0)
321
+ return [];
322
+ const trimmed = filter?.trim();
323
+ let matching = records;
324
+ if (trimmed) {
325
+ if (trimmed.endsWith('.md')) {
326
+ const bare = trimmed.slice(0, -3);
327
+ const byFilename = records.find((record) => basename(record.filePath, '.md') === bare);
328
+ matching = byFilename ? [byFilename] : [];
329
+ }
330
+ else {
331
+ const lower = trimmed.toLowerCase();
332
+ matching = records.filter((record) => {
333
+ const url = (record.data?.url || '').toLowerCase();
334
+ if (!url)
335
+ return false;
336
+ return url.includes(lower);
337
+ });
338
+ }
339
+ }
340
+ if (options?.recency) {
341
+ const cutoff = Date.now() - RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000;
342
+ matching = matching.filter((record) => {
343
+ const isRecent = record.mtime.getTime() >= cutoff;
344
+ return options.recency === 'recent' ? isRecent : !isRecent;
345
+ });
346
+ }
347
+ const sorted = matching.sort((a, b) => {
348
+ const aUrl = a.data?.url || '';
349
+ const bUrl = b.data?.url || '';
350
+ return aUrl.localeCompare(bUrl);
351
+ });
352
+ const toc = [];
353
+ for (let i = 0; i < sorted.length; i++) {
354
+ const record = sorted[i];
355
+ const sections = listTocHeadings(record.content);
356
+ if (sections.length === 0)
357
+ continue;
358
+ toc.push({
359
+ fileTag: indexToLetters(toc.length),
360
+ fileHash: basename(record.filePath, '.md'),
361
+ url: record.data?.url || '',
362
+ sections,
363
+ });
364
+ }
365
+ return toc;
366
+ }
367
+ getExperienceSectionByTag(fileTag, sectionIndex, filter) {
368
+ const toc = this.listAllExperienceToc(filter);
369
+ const entry = toc.find((e) => e.fileTag === fileTag);
370
+ if (!entry)
371
+ return null;
372
+ const filePath = this.findExperienceFileByHash(entry.fileHash);
373
+ if (!filePath)
374
+ return null;
375
+ const { content } = this.readExperienceFile(entry.fileHash);
376
+ const extracted = extractHeadingSection(content, sectionIndex);
377
+ if (!extracted)
378
+ return null;
379
+ return { title: extracted.title, url: entry.url, content: extracted.body, fileHash: entry.fileHash };
380
+ }
342
381
  }
343
382
  function listTocHeadings(content) {
344
383
  const tokens = marked.lexer(content);
@@ -403,7 +442,11 @@ export function renderExperienceToc(toc) {
403
442
  return '';
404
443
  const lines = [];
405
444
  lines.push('<experience>');
406
- lines.push('Past experience for this page. Call learn_experience({ fileTag, sectionIndex }) to read a section.');
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.');
448
+ lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
449
+ lines.push('Call learn_experience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
407
450
  lines.push('');
408
451
  for (const entry of toc) {
409
452
  lines.push(`File ${entry.fileTag} ${entry.url}:`);
@@ -416,3 +459,51 @@ export function renderExperienceToc(toc) {
416
459
  lines.push('</experience>');
417
460
  return lines.join('\n');
418
461
  }
462
+ function normalizeTitle(raw) {
463
+ let t = (raw || '').trim();
464
+ for (const p of ['FLOW:', 'ACTION:']) {
465
+ if (t.toLowerCase().startsWith(p.toLowerCase())) {
466
+ t = t.slice(p.length).trim();
467
+ break;
468
+ }
469
+ }
470
+ while (t.length > 0 && '.!?,;:'.includes(t[t.length - 1])) {
471
+ t = t.slice(0, -1);
472
+ }
473
+ if (t.length > 0)
474
+ t = t[0].toLowerCase() + t.slice(1);
475
+ return t;
476
+ }
477
+ function generateActionContent(title, code, explanation) {
478
+ const lines = [];
479
+ lines.push(`## ACTION: ${title}`);
480
+ lines.push('');
481
+ if (explanation) {
482
+ lines.push(`Solution: ${explanation}`);
483
+ lines.push('');
484
+ }
485
+ lines.push('```javascript');
486
+ lines.push(code);
487
+ lines.push('```');
488
+ lines.push('');
489
+ return lines.join('\n');
490
+ }
491
+ function renderAsHowTo(content) {
492
+ const tokens = marked.lexer(content);
493
+ let result = '';
494
+ for (const token of tokens) {
495
+ if (token.type === 'heading' && token.depth === 2) {
496
+ const text = token.text.trim();
497
+ if (text.startsWith('FLOW:')) {
498
+ result += `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`;
499
+ continue;
500
+ }
501
+ if (text.startsWith('ACTION:')) {
502
+ result += `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
503
+ continue;
504
+ }
505
+ }
506
+ result += token.raw || '';
507
+ }
508
+ return result;
509
+ }