explorbot 0.4.0 → 0.4.2

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 (177) hide show
  1. package/bin/explorbot-cli.ts +13 -6
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  4. package/boat/api-tester/src/ai/curler.ts +7 -1
  5. package/boat/api-tester/src/apibot.ts +10 -4
  6. package/boat/api-tester/src/cli.ts +15 -2
  7. package/boat/api-tester/src/config.ts +28 -8
  8. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  9. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  10. package/boat/doc-collector/src/ai/tools.ts +1 -1
  11. package/boat/doc-collector/src/cli.ts +21 -3
  12. package/boat/doc-collector/src/config.ts +3 -0
  13. package/boat/doc-collector/src/docbot.ts +57 -12
  14. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  15. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  16. package/boat/doc-collector/src/screenshots.ts +106 -3
  17. package/boat/doc-collector/src/template-dedup.ts +31 -0
  18. package/boat/prima/bin/prima-cli.ts +2 -0
  19. package/boat/prima/src/cli.ts +5 -0
  20. package/boat/prima/src/prima.ts +3 -4
  21. package/dist/bin/explorbot-cli.js +14 -6
  22. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  23. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  24. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  25. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  26. package/dist/boat/api-tester/src/apibot.js +7 -3
  27. package/dist/boat/api-tester/src/cli.js +14 -2
  28. package/dist/boat/api-tester/src/config.js +31 -8
  29. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  30. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  31. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  32. package/dist/boat/doc-collector/src/cli.js +18 -3
  33. package/dist/boat/doc-collector/src/config.js +1 -0
  34. package/dist/boat/doc-collector/src/docbot.js +53 -11
  35. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  36. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  37. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  38. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  39. package/dist/boat/prima/bin/prima-cli.js +2 -0
  40. package/dist/boat/prima/src/cli.js +5 -0
  41. package/dist/boat/prima/src/prima.js +3 -4
  42. package/dist/package.json +1 -1
  43. package/dist/rules/planner/styles/normal.md +1 -1
  44. package/dist/src/action-result.d.ts +2 -2
  45. package/dist/src/action-result.js +15 -17
  46. package/dist/src/action.d.ts +1 -1
  47. package/dist/src/action.js +15 -15
  48. package/dist/src/ai/captain.js +1 -1
  49. package/dist/src/ai/fisherman/request-haul.d.ts +11 -0
  50. package/dist/src/ai/fisherman/request-haul.js +28 -0
  51. package/dist/src/ai/fisherman-tools.d.ts +10 -1
  52. package/dist/src/ai/fisherman-tools.js +35 -31
  53. package/dist/src/ai/fisherman.d.ts +3 -2
  54. package/dist/src/ai/fisherman.js +8 -6
  55. package/dist/src/ai/navigator.js +1 -1
  56. package/dist/src/ai/pilot.js +21 -16
  57. package/dist/src/ai/planner.js +9 -7
  58. package/dist/src/ai/provider.js +8 -14
  59. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  60. package/dist/src/ai/researcher/coordinates.js +13 -1
  61. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  62. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  63. package/dist/src/ai/researcher.js +3 -1
  64. package/dist/src/ai/rules.js +11 -3
  65. package/dist/src/ai/tester.js +20 -20
  66. package/dist/src/ai/tools.d.ts +1 -1
  67. package/dist/src/ai/tools.js +18 -74
  68. package/dist/src/api/request-store.d.ts +1 -5
  69. package/dist/src/api/request-store.js +12 -22
  70. package/dist/src/api/spec-reader.js +1 -1
  71. package/dist/src/commands/config-command.js +3 -10
  72. package/dist/src/commands/drill-command.js +1 -1
  73. package/dist/src/commands/explore-command.js +12 -1
  74. package/dist/src/commands/index.js +2 -0
  75. package/dist/src/commands/init-command.js +3 -7
  76. package/dist/src/commands/options/base-option.d.ts +8 -0
  77. package/dist/src/commands/options/base-option.js +12 -0
  78. package/dist/src/commands/options/index.d.ts +5 -0
  79. package/dist/src/commands/options/index.js +5 -0
  80. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  81. package/dist/src/commands/options/knowledge-option.js +12 -0
  82. package/dist/src/commands/options/ws-option.d.ts +7 -0
  83. package/dist/src/commands/options/ws-option.js +21 -0
  84. package/dist/src/commands/plans-command.d.ts +4 -1
  85. package/dist/src/commands/plans-command.js +4 -4
  86. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  87. package/dist/src/commands/recommended-models-command.js +85 -0
  88. package/dist/src/config.d.ts +3 -0
  89. package/dist/src/config.js +18 -2
  90. package/dist/src/experience-tracker.js +1 -1
  91. package/dist/src/explorbot.js +4 -4
  92. package/dist/src/global-config.d.ts +2 -0
  93. package/dist/src/global-config.js +6 -0
  94. package/dist/src/knowledge-tracker.d.ts +20 -7
  95. package/dist/src/knowledge-tracker.js +69 -31
  96. package/dist/src/remote.d.ts +0 -3
  97. package/dist/src/remote.js +0 -18
  98. package/dist/src/state-manager.d.ts +3 -3
  99. package/dist/src/state-manager.js +8 -8
  100. package/dist/src/test-plan.d.ts +1 -0
  101. package/dist/src/test-plan.js +13 -9
  102. package/dist/src/utils/aria.d.ts +1 -1
  103. package/dist/src/utils/aria.js +23 -2
  104. package/dist/src/utils/cli-section.d.ts +1 -0
  105. package/dist/src/utils/cli-section.js +12 -0
  106. package/dist/src/utils/html-diff.d.ts +1 -0
  107. package/dist/src/utils/html-diff.js +5 -5
  108. package/dist/src/utils/html.d.ts +2 -0
  109. package/dist/src/utils/html.js +5 -4
  110. package/dist/src/utils/overlay.d.ts +19 -55
  111. package/dist/src/utils/overlay.js +94 -148
  112. package/dist/src/utils/region.d.ts +41 -0
  113. package/dist/src/utils/region.js +99 -0
  114. package/dist/src/utils/similarity.d.ts +1 -0
  115. package/dist/src/utils/similarity.js +11 -0
  116. package/dist/src/utils/url-matcher.d.ts +1 -1
  117. package/dist/src/utils/url-matcher.js +4 -4
  118. package/docs/api-testing/basics.md +15 -0
  119. package/docs/api-testing/planning.md +10 -1
  120. package/docs/doc-collection/basics.md +11 -5
  121. package/docs/doc-collection/crawling.md +21 -1
  122. package/docs/reference/commands.md +27 -4
  123. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  124. package/docs/workflow/agentic-usage.md +11 -2
  125. package/docs/workflow/application-spec.md +4 -0
  126. package/docs/workflow/knowledge.md +46 -2
  127. package/package.json +1 -1
  128. package/rules/planner/styles/normal.md +1 -1
  129. package/src/action-result.ts +15 -16
  130. package/src/action.ts +16 -15
  131. package/src/ai/captain.ts +1 -1
  132. package/src/ai/fisherman/request-haul.ts +32 -0
  133. package/src/ai/fisherman-tools.ts +38 -32
  134. package/src/ai/fisherman.ts +8 -7
  135. package/src/ai/navigator.ts +1 -1
  136. package/src/ai/pilot.ts +19 -14
  137. package/src/ai/planner.ts +9 -8
  138. package/src/ai/provider.ts +8 -12
  139. package/src/ai/researcher/coordinates.ts +13 -1
  140. package/src/ai/researcher/deep-analysis.ts +9 -9
  141. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  142. package/src/ai/researcher.ts +2 -1
  143. package/src/ai/rules.ts +11 -3
  144. package/src/ai/tester.ts +18 -18
  145. package/src/ai/tools.ts +18 -86
  146. package/src/api/request-store.ts +13 -27
  147. package/src/api/spec-reader.ts +1 -1
  148. package/src/commands/config-command.ts +3 -8
  149. package/src/commands/drill-command.ts +1 -1
  150. package/src/commands/explore-command.ts +12 -1
  151. package/src/commands/index.ts +2 -0
  152. package/src/commands/init-command.ts +4 -11
  153. package/src/commands/options/base-option.ts +18 -0
  154. package/src/commands/options/index.ts +7 -0
  155. package/src/commands/options/knowledge-option.ts +14 -0
  156. package/src/commands/options/ws-option.ts +24 -0
  157. package/src/commands/plans-command.ts +5 -5
  158. package/src/commands/recommended-models-command.ts +92 -0
  159. package/src/config.ts +20 -2
  160. package/src/experience-tracker.ts +1 -1
  161. package/src/explorbot.ts +4 -4
  162. package/src/global-config.ts +8 -0
  163. package/src/knowledge-tracker.ts +94 -36
  164. package/src/remote.ts +0 -20
  165. package/src/state-manager.ts +10 -9
  166. package/src/test-plan.ts +12 -10
  167. package/src/utils/aria.ts +26 -3
  168. package/src/utils/cli-section.ts +12 -0
  169. package/src/utils/html-diff.ts +6 -6
  170. package/src/utils/html.ts +6 -4
  171. package/src/utils/overlay.ts +105 -192
  172. package/src/utils/region.ts +132 -0
  173. package/src/utils/similarity.ts +9 -0
  174. package/src/utils/url-matcher.ts +3 -3
  175. package/dist/src/utils/request-map.d.ts +0 -7
  176. package/dist/src/utils/request-map.js +0 -16
  177. package/src/utils/request-map.ts +0 -19
@@ -62,6 +62,15 @@ export async function visuallyAnnotateContainers(page, containers) {
62
62
  return count;
63
63
  }, containers);
64
64
  }
65
+ export async function removeVisualAnnotations(page) {
66
+ try {
67
+ await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
68
+ for (const element of elements)
69
+ element.remove();
70
+ });
71
+ }
72
+ catch { }
73
+ }
65
74
  export function WithCoordinates(Base) {
66
75
  return class extends Base {
67
76
  analyzeScreenshotForVisualProps() {
@@ -70,6 +79,9 @@ export function WithCoordinates(Base) {
70
79
  async visuallyAnnotateElements(opts) {
71
80
  return this.explorer.withPage((page) => visuallyAnnotateContainers(page, opts?.containers || []));
72
81
  }
82
+ async removeVisualAnnotations() {
83
+ await this.explorer.withPage((page) => removeVisualAnnotations(page));
84
+ }
73
85
  async _analyzeScreenshotForVisualProps() {
74
86
  const elements = new Map();
75
87
  const emptyResult = { elements, pagePurpose: null, primaryActions: null, focusedSection: null };
@@ -81,7 +93,7 @@ export function WithCoordinates(Base) {
81
93
  tag('step').log('Analyzing annotated screenshot for visual properties');
82
94
  const prompt = dedent `
83
95
  This screenshot has two types of annotations:
84
- - **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-left maps dashed line colors to section names. Ignore containers for this task.
96
+ - **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-right maps dashed line colors to section names. Ignore containers for this task.
85
97
  - **Interactive elements**: solid bordered boxes with eidx numbers in the top-right corner above the box. Adjacent elements use different colors.
86
98
 
87
99
  For each interactive element (solid border, eidx number), report:
@@ -62,29 +62,29 @@ export function WithDeepAnalysis(Base) {
62
62
  this._appendExtendedResearch(result, expandedSections, navigationLinks);
63
63
  }
64
64
  async researchOverlay(current, previous, pageStateHash) {
65
- const focusArea = current.overlay;
66
- if (!focusArea.present || !focusArea.name)
65
+ const region = current.overlay;
66
+ if (!region.isOpen || !region.name)
67
67
  return null;
68
68
  const cached = getCachedResearch(pageStateHash);
69
69
  if (!cached)
70
70
  return null;
71
- const escaped = focusArea.name.replace(/"/g, '\\"');
71
+ const escaped = region.name.replace(/"/g, '\\"');
72
72
  if (mdq(cached).query(`section3(~"${escaped}")`).count() > 0) {
73
- debugLog(`Overlay "${focusArea.name}" already in cached research, skipping`);
73
+ debugLog(`Overlay "${region.name}" already in cached research, skipping`);
74
74
  return null;
75
75
  }
76
76
  const diff = await current.diff(previous);
77
77
  if (!diff.ariaChanged && diff.htmlParts.length === 0) {
78
- debugLog(`No diff between current and previous state for overlay "${focusArea.name}"`);
78
+ debugLog(`No diff between current and previous state for overlay "${region.name}"`);
79
79
  return null;
80
80
  }
81
81
  const alreadyExpanded = this._summarizeExpanded(parseResearchSections(cached)
82
82
  .filter((s) => s.elements.length > 0)
83
83
  .map((s) => s.rawMarkdown));
84
- tag('substep').log(`Researching overlay: ${focusArea.name}`);
85
- const sectionMarkdown = await this._analyzeExpandedAction('', focusArea.name, diff, alreadyExpanded);
84
+ tag('substep').log(`Researching overlay: ${region.name}`);
85
+ const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
86
86
  if (!sectionMarkdown) {
87
- debugLog(`Overlay "${focusArea.name}" produced no meaningful expansion`);
87
+ debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
88
88
  return null;
89
89
  }
90
90
  const extQuery = mdq(cached).query('section1(~"Extended Research")');
@@ -97,7 +97,7 @@ export function WithDeepAnalysis(Base) {
97
97
  updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
98
98
  }
99
99
  saveResearch({ hash: pageStateHash }, updated);
100
- tag('substep').log(`Overlay research appended: ${focusArea.name}`);
100
+ tag('substep').log(`Overlay research appended: ${region.name}`);
101
101
  return sectionMarkdown;
102
102
  }
103
103
  _loadPreviousExtendedSections(hash) {
@@ -3,17 +3,7 @@ import { join } from 'node:path';
3
3
  import { parentPort } from 'node:worker_threads';
4
4
  import { computeHtmlFingerprint } from "../../utils/html-diff.js";
5
5
  import { isSamePageFamily } from "../../utils/url-matcher.js";
6
- function diceSimilarity(a, b) {
7
- let intersection = 0;
8
- for (const item of a) {
9
- if (b.has(item))
10
- intersection++;
11
- }
12
- const total = a.size + b.size;
13
- if (total === 0)
14
- return 100;
15
- return Math.round(((2 * intersection) / total) * 100);
16
- }
6
+ import { diceSimilarity } from "../../utils/similarity.js";
17
7
  parentPort.on('message', (data) => {
18
8
  const { html, statesDir, maxAgeMs, threshold, url } = data;
19
9
  if (!existsSync(statesDir)) {
@@ -53,6 +53,8 @@ export class Researcher extends ResearcherBase {
53
53
  throw new Error('not implemented');
54
54
  }
55
55
  static getCachedResearch(state) {
56
+ if (state instanceof ActionResult)
57
+ return getCachedResearch(state.baseHash);
56
58
  return getCachedResearch(ActionResult.fromState(state).baseHash);
57
59
  }
58
60
  getSystemMessage() {
@@ -191,7 +193,7 @@ export class Researcher extends ResearcherBase {
191
193
  const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
192
194
  await this.visuallyAnnotateElements({ containers });
193
195
  this.actionResult = await this.explorer.capture({ screenshot: true });
194
- const visualResult = await this.analyzeScreenshotForVisualProps();
196
+ const visualResult = await this.analyzeScreenshotForVisualProps().finally(() => this.removeVisualAnnotations());
195
197
  if (visualResult.elements.size > 0) {
196
198
  await this.mergeVisualData(result, visualResult.elements);
197
199
  result.parseLocators();
@@ -145,11 +145,11 @@ export const protectionRule = dedent `
145
145
 
146
146
  Pre-existing data on the page belongs to the application, not the test.
147
147
  Items that were not created inside the current test scenario must not be deleted, removed, emptied, reset, archived, or otherwise destroyed.
148
- If a scenario needs to verify destructive behaviour, the same scenario must first create a disposable target and then destroy that specific target — never operate on data that was already there when the test started.
148
+ If a scenario needs to verify destructive behaviour, the same scenario must first create its own target and then destroy that specific target — never operate on data that was already there when the test started.
149
149
 
150
150
  The resource that the current page URL represents is "under test".
151
151
  The test must not destroy the resource it is running against — doing so invalidates every subsequent scenario that starts on the same URL.
152
- Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on disposable children created within the scenario itself.
152
+ Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on children created within the scenario itself.
153
153
  </important>
154
154
  `;
155
155
  export const dataProtectionRules = dedent `
@@ -164,7 +164,7 @@ export const dataProtectionRules = dedent `
164
164
  filter, tab, or list-inspection constraint. Use visible existing data when it is available.
165
165
  If no suitable data exists, report the missing precondition instead of creating data.
166
166
 
167
- Destructive actions are allowed only against disposable data created by the current scenario
167
+ Destructive actions are allowed only against data created by the current scenario
168
168
  or prepared for that scenario by Fisherman/API preconditions. Existing application data must
169
169
  remain unchanged.
170
170
  </data_protection_rules>
@@ -318,6 +318,14 @@ export const actionRule = dedent `
318
318
  If locator doesn't work, try CSS or XPath locators.
319
319
  If nothing works, use I.clickXY(x, y) as last resort.
320
320
 
321
+ When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
322
+ Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
323
+
324
+ <example>
325
+ I.click('Remove', step.opts({ elementIndex: 2 }));
326
+ I.click({ role: 'link', text: 'Details' }, '.panel', step.opts({ elementIndex: 1 }));
327
+ </example>
328
+
321
329
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
322
330
 
323
331
 
@@ -486,14 +486,14 @@ export class Tester extends TaskAgent {
486
486
  const currentStateHash = currentState.hash;
487
487
  const isNewUrl = this.previousUrl !== currentUrl;
488
488
  const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
489
- if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
489
+ if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.isOpen) {
490
490
  this.regionTransitioned = true;
491
491
  }
492
- this.previousRegionPresent = currentState.overlay.present;
492
+ this.previousRegionPresent = currentState.overlay.isOpen;
493
493
  this.previousUrl = currentUrl;
494
494
  this.previousStateHash = currentStateHash;
495
495
  let context = '';
496
- const focusArea = currentState.overlay;
496
+ const region = currentState.overlay;
497
497
  const focusedElement = currentState.focusedElement;
498
498
  if (focusedElement) {
499
499
  const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
@@ -511,30 +511,30 @@ export class Tester extends TaskAgent {
511
511
  </no_focus>
512
512
  `;
513
513
  }
514
- if (focusArea.detected) {
515
- const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
514
+ if (region.isModal) {
515
+ const areaName = region.name ? ` "${region.name}"` : '';
516
516
  let rootHint = '';
517
- if (focusArea.root)
518
- rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
517
+ if (region.root)
518
+ rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
519
519
  context += dedent `
520
- <focus_scope>
521
- A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
522
- Scope all interactions to elements inside this ${focusArea.type}.
523
- Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the ${focusArea.type}.
524
- Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
525
- </focus_scope>
520
+ <overlay>
521
+ An overlay${areaName} is currently open above the page.${rootHint}
522
+ Scope all interactions to elements inside this overlay.
523
+ Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the overlay.
524
+ Use <page_aria> to confirm the element you target is actually inside the overlay.
525
+ </overlay>
526
526
  `;
527
527
  }
528
- if (!focusArea.detected && focusArea.present && isNewState) {
528
+ if (!region.isModal && region.isOpen && isNewState) {
529
529
  let rootHint = '';
530
- if (focusArea.root)
531
- rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
530
+ if (region.root)
531
+ rootHint = `\nIt lives inside \`${region.root}\`.`;
532
532
  context += dedent `
533
- <area_of_interest>
534
- A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
533
+ <region>
534
+ A large new region "${region.name || 'unnamed region'}" appeared on this page without navigation.${rootHint}
535
535
  The scenario most likely continues inside this area — prefer its elements for your next actions.
536
536
  The rest of the page (navigation, menus, filters) is still interactive and remains available.
537
- </area_of_interest>
537
+ </region>
538
538
  `;
539
539
  }
540
540
  if (currentState.isInsideIframe) {
@@ -594,7 +594,7 @@ export class Tester extends TaskAgent {
594
594
  `;
595
595
  return context;
596
596
  }
597
- if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
597
+ if (region.isOpen && region.name && this.pageStateHash && this.pageActionResult) {
598
598
  const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
599
599
  if (overlaySection) {
600
600
  context += dedent `
@@ -12,7 +12,7 @@ interface AgentToolDeps extends ToolDeps {
12
12
  withExperience?: boolean;
13
13
  }
14
14
  export declare const ASSERTION_TOOLS: readonly ["verify"];
15
- export declare function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, task: Task): {
15
+ export declare function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task): {
16
16
  click: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
17
17
  commands: any;
18
18
  explanation: any;
@@ -16,7 +16,7 @@ import { sectionContextRule } from "./rules.js";
16
16
  import { isInteractive } from "./task-agent.js";
17
17
  const debugLog = createDebug('explorbot:tools');
18
18
  export const ASSERTION_TOOLS = ['verify'];
19
- export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
19
+ export function createCodeceptJSTools({ explorer, stateManager }, task) {
20
20
  return {
21
21
  click: tool({
22
22
  description: dedent `
@@ -29,6 +29,12 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
29
29
  Container narrows search area. Use when page has multiple matching elements.
30
30
  Example: Page has 3 "Delete" buttons in different rows:
31
31
  I.click("Delete", ".row-1") - clicks Delete inside element with class row-1
32
+ I.click(locator, step.opts({ elementIndex: N })) - click the Nth element the locator matches
33
+ Use after a result reported several matches. N is the "Element N" number from that list.
34
+ step.opts() always goes LAST, after the container when there is one.
35
+ Example: the result listed 3 "Delete" buttons and you want the second:
36
+ I.click("Delete", step.opts({ elementIndex: 2 }))
37
+ I.click("Delete", ".table", step.opts({ elementIndex: 2 })) - same, narrowed to a container
32
38
 
33
39
  IMPORTANT: This tool ONLY accepts click commands. For typing text, use form() tool.
34
40
  CRITICAL: All commands MUST target the SAME element using different locators.
@@ -47,6 +53,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
47
53
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
48
54
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
49
55
  5. I.clickXY(x, y) - coordinates fallback
56
+ After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
50
57
  `),
51
58
  explanation: z.string().describe('Why you are clicking this element'),
52
59
  }),
@@ -72,6 +79,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
72
79
  const previousState = ActionResult.fromState(stateManager.getCurrentState());
73
80
  const action = explorer.action();
74
81
  const attempts = [];
82
+ let ambiguityError = null;
75
83
  for (let i = 0; i < commands.length; i++) {
76
84
  const command = transformContainsCommand(commands[i]);
77
85
  const success = await action.attempt(command, explanation);
@@ -79,34 +87,14 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
79
87
  if (action.lastError)
80
88
  attempt.error = errorText(action.lastError);
81
89
  attempts.push(attempt);
90
+ if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound')
91
+ ambiguityError = action.lastError;
82
92
  if (success) {
83
93
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
84
94
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
85
95
  return successToolResult('click', { ...toolResult, attempts, code: command }, action);
86
96
  }
87
97
  }
88
- let disambiguated = null;
89
- if (attempts.some((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
90
- disambiguated = await disambiguateElements(action.lastError, explanation, ai);
91
- }
92
- if (disambiguated) {
93
- debugLog('Disambiguation picked element %d', disambiguated.position);
94
- const failedCommand = attempts.find((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))?.command;
95
- const retryCommands = [];
96
- if (failedCommand) {
97
- retryCommands.push(failedCommand.replace(/\)$/, `, step.opts({ elementIndex: ${disambiguated.position} }))`));
98
- }
99
- retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
100
- for (const retryCmd of retryCommands) {
101
- if (!(await action.attempt(retryCmd, explanation))) {
102
- attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
103
- continue;
104
- }
105
- const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, retryCmd);
106
- await commitNote(activeNote, TestResult.PASSED, toolResult, action);
107
- return successToolResult('click', { ...toolResult, attempts, code: retryCmd, disambiguated: true }, action);
108
- }
109
- }
110
98
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
111
99
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
112
100
  const suggestion = clickFailureSuggestion(attempts);
@@ -114,7 +102,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
114
102
  ...toolResult,
115
103
  attempts,
116
104
  suggestion,
117
- }, action.lastError);
105
+ }, ambiguityError || action.lastError);
118
106
  },
119
107
  }),
120
108
  hover: tool({
@@ -344,13 +332,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
344
332
  if (action.lastError) {
345
333
  const message = errorText(action.lastError);
346
334
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
347
- let formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
348
- if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
349
- const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
350
- if (disambiguated) {
351
- formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
352
- }
353
- }
335
+ const formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
354
336
  return failedToolResult('form', `Form execution FAILED! ${message}\n${formatExecutedSteps(action.executedSteps, codeLines.length)}`, {
355
337
  ...toolResult,
356
338
  code: codeBlock,
@@ -1123,13 +1105,11 @@ export async function failedToolResult(action, message, data, error) {
1123
1105
  }
1124
1106
  function getMultipleElementsSuggestion() {
1125
1107
  return dedent `
1126
- Multiple elements matched your locator. To fix this:
1127
- 1. Use container context: I.click({ "role": "button", "text": "Submit" }, '.form-container')
1128
- 2. Use more specific CSS: target the actual element (input, button, a) not wrapper divs
1129
- 3. Add distinguishing attributes: input[type="submit"], button[type="submit"], [value="..."]
1130
- 4. If buttons have similar text like "Create" and "Create Demo", use the FULL unique text
1131
- 5. Use xpathCheck() to inspect matched elements and pick the correct one
1132
- 6. Use visualClick() to click the right element by visual appearance
1108
+ Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1109
+ Read the numbered elements list and click the one you meant by its number:
1110
+ reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1111
+ If none of them is the element you want, narrow the locator with a container or its full unique text.
1112
+ If the list is missing, call xpathCheck() to see what the locator matches.
1133
1113
  `;
1134
1114
  }
1135
1115
  export function withdrawVisionTools(tools) {
@@ -1194,42 +1174,6 @@ export async function formatMatchedElements(error) {
1194
1174
  return 'Could not fetch element details. Repeat the action to get better info.';
1195
1175
  return formatElementList(details);
1196
1176
  }
1197
- async function disambiguateElements(error, explanation, provider) {
1198
- const elementDetails = await extractWebElements(error);
1199
- if (!elementDetails)
1200
- return null;
1201
- const elementList = formatElementList(elementDetails);
1202
- const schema = z.object({
1203
- position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
1204
- });
1205
- try {
1206
- const result = await provider.generateObject([
1207
- {
1208
- role: 'user',
1209
- content: dedent `
1210
- A click action failed because multiple elements matched the locator.
1211
- The intended action was: ${explanation}
1212
-
1213
- Here are the matched elements:
1214
-
1215
- ${elementList}
1216
-
1217
- Which element (1-${elementDetails.length}) best matches the intended action?
1218
- Return the position number, or null if none of them match.
1219
- `,
1220
- },
1221
- ], schema, provider.getModelForAgent(), { agentName: 'disambiguator', timeout: 15000 });
1222
- const position = result?.object?.position;
1223
- if (position && position >= 1 && position <= elementDetails.length) {
1224
- return { position, xpath: elementDetails[position - 1].xpath };
1225
- }
1226
- return null;
1227
- }
1228
- catch (e) {
1229
- debugLog('Element disambiguation AI call failed: %s', e);
1230
- return null;
1231
- }
1232
- }
1233
1177
  function getNotFoundSuggestion(errorMessage) {
1234
1178
  if (!errorMessage.includes('not found')) {
1235
1179
  return null;
@@ -12,14 +12,9 @@ export declare class RequestStore {
12
12
  getFailedRequests(): RequestResult[];
13
13
  onFailedRequest(cb: (r: RequestResult) => void): () => void;
14
14
  addMadeRequest(result: RequestResult): void;
15
- addRequest(result: RequestResult): void;
16
15
  getCapturedRequests(): RequestResult[];
17
16
  getMadeRequests(): RequestResult[];
18
- getRequests(): RequestResult[];
19
17
  getLastRequest(): RequestResult | undefined;
20
- getRequestsByEndpoint(pathPrefix: string): RequestResult[];
21
- getRequestsByMethod(method: string): RequestResult[];
22
- getRequestsByStatus(status: number): RequestResult[];
23
18
  toEndpointList(scopePath?: string): string;
24
19
  extractAuthHeaders(): Record<string, string>;
25
20
  findCapturedRequest(method: string, searchPath: string): RequestResult | undefined;
@@ -28,3 +23,4 @@ export declare class RequestStore {
28
23
  getWriteRequestsForScope(scopePath: string): RequestResult[];
29
24
  clear(): void;
30
25
  }
26
+ export declare function isFailedRequest(request: RequestResult): boolean;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { isDynamicSegment } from "../utils/url-matcher.js";
3
+ import { generalizeUrl, isDynamicSegment } from "../utils/url-matcher.js";
4
4
  import { RequestResult } from "./request-result.js";
5
5
  const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
6
6
  export class RequestStore {
@@ -38,31 +38,15 @@ export class RequestStore {
38
38
  this.madeRequests.push(result);
39
39
  result.save(this.outputDir);
40
40
  }
41
- addRequest(result) {
42
- this.addMadeRequest(result);
43
- }
44
41
  getCapturedRequests() {
45
42
  return this.capturedRequests;
46
43
  }
47
44
  getMadeRequests() {
48
45
  return this.madeRequests;
49
46
  }
50
- getRequests() {
51
- return this.madeRequests;
52
- }
53
47
  getLastRequest() {
54
48
  return this.madeRequests[this.madeRequests.length - 1];
55
49
  }
56
- getRequestsByEndpoint(pathPrefix) {
57
- return this.madeRequests.filter((r) => r.path.startsWith(pathPrefix));
58
- }
59
- getRequestsByMethod(method) {
60
- const upper = method.toUpperCase();
61
- return this.madeRequests.filter((r) => r.method === upper);
62
- }
63
- getRequestsByStatus(status) {
64
- return this.madeRequests.filter((r) => r.status === status);
65
- }
66
50
  toEndpointList(scopePath) {
67
51
  let requests = this.capturedRequests;
68
52
  if (scopePath)
@@ -70,7 +54,7 @@ export class RequestStore {
70
54
  const seen = new Set();
71
55
  const lines = [];
72
56
  for (const req of requests) {
73
- const key = `${req.method} ${normalizePathPattern(req.path)}`;
57
+ const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`;
74
58
  if (seen.has(key))
75
59
  continue;
76
60
  seen.add(key);
@@ -92,13 +76,17 @@ export class RequestStore {
92
76
  }
93
77
  findCapturedRequest(method, searchPath) {
94
78
  const upper = method.toUpperCase();
95
- const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
79
+ const search = generalizeUrl(searchPath, () => '{id}')
80
+ .split('/')
81
+ .filter(Boolean);
96
82
  let best;
97
83
  let bestScore = -1;
98
84
  for (const req of this.capturedRequests) {
99
85
  if (req.method !== upper)
100
86
  continue;
101
- const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
87
+ const segments = generalizeUrl(req.path, () => '{id}')
88
+ .split('/')
89
+ .filter(Boolean);
102
90
  if (segments.length < search.length)
103
91
  continue;
104
92
  if (!search.every((segment, i) => segment === segments[i]))
@@ -139,8 +127,7 @@ export class RequestStore {
139
127
  }
140
128
  }
141
129
  getWriteRequestsForScope(scopePath) {
142
- const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
143
- const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
130
+ const writes = this.capturedRequests.filter((r) => r.isWrite);
144
131
  const scopeSegments = scopePath.split('/').filter(Boolean);
145
132
  if (scopeSegments.length === 0)
146
133
  return writes;
@@ -172,6 +159,9 @@ export class RequestStore {
172
159
  this.failedRequests = [];
173
160
  }
174
161
  }
162
+ export function isFailedRequest(request) {
163
+ return request.status >= 400 || Boolean(request.error);
164
+ }
175
165
  function normalizePathPattern(urlPath) {
176
166
  return urlPath
177
167
  .split('/')
@@ -5,7 +5,7 @@ import { dereference } from '@scalar/openapi-parser';
5
5
  import { tag } from "../utils/logger.js";
6
6
  export function validateSpecs(specs) {
7
7
  if (!specs?.length) {
8
- throw new Error('API spec is required. Set api.spec in your config file.');
8
+ throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
9
9
  }
10
10
  }
11
11
  export async function loadSpec(specPaths, outputDir) {
@@ -5,6 +5,7 @@ import { ConfigParser, EXPLORBOT_ENV_VARS, configuredModels } from '../config.js
5
5
  import { listSites } from '../global-config.js';
6
6
  import { Reporter } from '../reporter.js';
7
7
  import { getCliName } from '../utils/cli-name.js';
8
+ import { renderSection } from '../utils/cli-section.js';
8
9
  import { tag } from '../utils/logger.js';
9
10
  import { BaseCommand } from './base-command.js';
10
11
  export class ConfigCommand extends BaseCommand {
@@ -32,7 +33,7 @@ export class ConfigCommand extends BaseCommand {
32
33
  const dirs = {};
33
34
  if (options.root) {
34
35
  for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
35
- dirs[name] = path.join(options.root, dir);
36
+ dirs[name] = path.resolve(options.root, dir);
36
37
  }
37
38
  }
38
39
  const env = {};
@@ -68,15 +69,7 @@ export class ConfigCommand extends BaseCommand {
68
69
  if (options.json)
69
70
  return JSON.stringify(data, null, 2);
70
71
  const lines = [];
71
- const section = (title, entries) => {
72
- if (!entries.length)
73
- return;
74
- const width = Math.max(...entries.map(([label]) => label.length));
75
- lines.push(chalk.bold(title));
76
- for (const [label, value] of entries)
77
- lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
78
- lines.push('');
79
- };
72
+ const section = (title, entries) => lines.push(...renderSection(title, entries));
80
73
  const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
81
74
  if (data.url)
82
75
  general.push(['url', data.url]);
@@ -21,7 +21,7 @@ export class DrillCommand extends BaseCommand {
21
21
  });
22
22
  }
23
23
  parseKnowledgeArg(args) {
24
- const match = args.match(/--knowledge\s+(\S+)/);
24
+ const match = args.match(/--save-knowledge\s+(\S+)/);
25
25
  return match ? match[1] : undefined;
26
26
  }
27
27
  parseMaxArg(args) {
@@ -256,9 +256,15 @@ export class ExploreCommand extends BaseCommand {
256
256
  tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
257
257
  try {
258
258
  await this.explorBot.visit(pick.url);
259
+ const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
260
+ if (errorPage) {
261
+ tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
262
+ this.failedSubPages.add(normalizeUrl(pick.url));
263
+ continue;
264
+ }
259
265
  await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
260
266
  const subPlan = this.explorBot.getCurrentPlan();
261
- if (subPlan && !this.completedPlans.includes(subPlan)) {
267
+ if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
262
268
  this.completedPlans.push(subPlan);
263
269
  }
264
270
  knownUrls.add(normalizeUrl(pick.url));
@@ -297,6 +303,11 @@ export class ExploreCommand extends BaseCommand {
297
303
  if (this.dryRun)
298
304
  opts.noSave = true;
299
305
  await this.planWithRetry(feature, opts, pageUrl);
306
+ const plan = this.explorBot.getCurrentPlan();
307
+ if (plan && plan.tests.length === 0) {
308
+ tag('warning').log('Nothing to test on this page, moving on');
309
+ return;
310
+ }
300
311
  await this.runPendingTests();
301
312
  this.rememberCurrentPlan();
302
313
  fresh = false;
@@ -26,6 +26,7 @@ import { PlanLoadCommand } from './plan-load-command.js';
26
26
  import { PlanReloadCommand } from './plan-reload-command.js';
27
27
  import { PlanSaveCommand } from './plan-save-command.js';
28
28
  import { PlansCommand } from './plans-command.js';
29
+ import { RecommendedModelsCommand } from './recommended-models-command.js';
29
30
  import { RerunCommand } from './rerun-command.js';
30
31
  import { ResearchCommand } from './research-command.js';
31
32
  import { RunsCommand } from './runs-command.js';
@@ -66,6 +67,7 @@ const commandClasses = [
66
67
  RerunCommand,
67
68
  StatusCommand,
68
69
  ConfigCommand,
70
+ RecommendedModelsCommand,
69
71
  DebugCommand,
70
72
  ExitCommand,
71
73
  ];