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
@@ -11,6 +11,7 @@ import { type AIConfig, configuredModels, modelName as getModelName } from '../c
11
11
  import { executionController } from '../execution-controller.ts';
12
12
  import { Observability } from '../observability.ts';
13
13
  import { Stats } from '../stats.ts';
14
+ import { getCliName } from '../utils/cli-name.js';
14
15
  import { createDebug, tag } from '../utils/logger.js';
15
16
  import { type RetryOptions, withRetry } from '../utils/retry.js';
16
17
  import { RulesLoader } from '../utils/rules-loader.ts';
@@ -51,12 +52,7 @@ export async function flushTelemetry(): Promise<void> {
51
52
  const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
52
53
 
53
54
  function extractCachedTokens(usage: any): number {
54
- if (!usage) return 0;
55
- const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
56
- if (typeof direct === 'number') return direct;
57
- const raw = usage.raw;
58
- const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
59
- return typeof fromRaw === 'number' ? fromRaw : 0;
55
+ return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
60
56
  }
61
57
 
62
58
  function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController): Promise<never> {
@@ -110,7 +106,7 @@ export class Provider {
110
106
 
111
107
  constructor(config: AIConfig) {
112
108
  if (!config?.model) {
113
- throw new AiError('AI model is not configured. Set ai.model in your config file.');
109
+ throw new AiError(`AI model is not configured. Set ai.model in your config file, or pick one with ${getCliName()} recommended-models`);
114
110
  }
115
111
  this.config = config;
116
112
  this.initLangfuse();
@@ -243,8 +239,8 @@ export class Provider {
243
239
  private recordUsage(agentName: string, modelName: string, usage: any): void {
244
240
  if (!usage) return;
245
241
  Stats.recordTokens(agentName, modelName, {
246
- input: usage.inputTokens ?? usage.promptTokens ?? 0,
247
- output: usage.outputTokens ?? usage.completionTokens ?? 0,
242
+ input: usage.inputTokens ?? 0,
243
+ output: usage.outputTokens ?? 0,
248
244
  total: usage.totalTokens ?? 0,
249
245
  cached: extractCachedTokens(usage),
250
246
  });
@@ -428,7 +424,7 @@ export class Provider {
428
424
  let invalidRequestFeedbackAdded = false;
429
425
  const executedStepMessages: ModelMessage[] = [];
430
426
  try {
431
- const response = await this.withModelRequestSlot(() =>
427
+ let response = await this.withModelRequestSlot(() =>
432
428
  withRetry(async () => {
433
429
  const stepMessages: ModelMessage[] = [];
434
430
  const onStepEnd = (step: any) => {
@@ -458,7 +454,7 @@ export class Provider {
458
454
 
459
455
  clearActivity();
460
456
 
461
- withExecutedSteps(response, executedStepMessages);
457
+ response = withExecutedSteps(response, executedStepMessages);
462
458
 
463
459
  // Log tool usage summary
464
460
  if (response.toolCalls && response.toolCalls.length > 0) {
@@ -651,7 +647,7 @@ export class Provider {
651
647
 
652
648
  async processImage(prompt: string, image: string): Promise<any> {
653
649
  if (!this.config.visionModel) {
654
- throw new Error('Vision model not configured. Please set ai.visionModel in your config.');
650
+ throw new Error(`Vision model not configured. Set ai.visionModel in your config; ${getCliName()} recommended-models lists the providers that serve one`);
655
651
  }
656
652
 
657
653
  setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
@@ -71,6 +71,14 @@ export async function visuallyAnnotateContainers(page: Page, containers: Array<{
71
71
  }, containers);
72
72
  }
73
73
 
74
+ export async function removeVisualAnnotations(page: Page): Promise<void> {
75
+ try {
76
+ await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
77
+ for (const element of elements) element.remove();
78
+ });
79
+ } catch {}
80
+ }
81
+
74
82
  export function WithCoordinates<T extends Constructor>(Base: T) {
75
83
  return class extends Base {
76
84
  declare explorer: Explorer;
@@ -85,6 +93,10 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
85
93
  return this.explorer.withPage((page) => visuallyAnnotateContainers(page, opts?.containers || []));
86
94
  }
87
95
 
96
+ async removeVisualAnnotations(): Promise<void> {
97
+ await this.explorer.withPage((page) => removeVisualAnnotations(page));
98
+ }
99
+
88
100
  private async _analyzeScreenshotForVisualProps(): Promise<VisualAnalysisResult> {
89
101
  const elements = new Map<string, { coordinates: string | null; color: string | null; icon: string | null }>();
90
102
  const emptyResult: VisualAnalysisResult = { elements, pagePurpose: null, primaryActions: null, focusedSection: null };
@@ -96,7 +108,7 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
96
108
 
97
109
  const prompt = dedent`
98
110
  This screenshot has two types of annotations:
99
- - **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.
111
+ - **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.
100
112
  - **Interactive elements**: solid bordered boxes with eidx numbers in the top-right corner above the box. Adjacent elements use different colors.
101
113
 
102
114
  For each interactive element (solid border, eidx number), report:
@@ -88,22 +88,22 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
88
88
  }
89
89
 
90
90
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
91
- const focusArea = current.overlay;
92
- if (!focusArea.present || !focusArea.name) return null;
91
+ const region = current.overlay;
92
+ if (!region.isOpen || !region.name) return null;
93
93
 
94
94
  const cached = getCachedResearch(pageStateHash);
95
95
  if (!cached) return null;
96
96
 
97
- const escaped = focusArea.name.replace(/"/g, '\\"');
97
+ const escaped = region.name.replace(/"/g, '\\"');
98
98
  if (mdq(cached).query(`section3(~"${escaped}")`).count() > 0) {
99
- debugLog(`Overlay "${focusArea.name}" already in cached research, skipping`);
99
+ debugLog(`Overlay "${region.name}" already in cached research, skipping`);
100
100
  return null;
101
101
  }
102
102
 
103
103
  const diff = await current.diff(previous);
104
104
 
105
105
  if (!diff.ariaChanged && diff.htmlParts.length === 0) {
106
- debugLog(`No diff between current and previous state for overlay "${focusArea.name}"`);
106
+ debugLog(`No diff between current and previous state for overlay "${region.name}"`);
107
107
  return null;
108
108
  }
109
109
 
@@ -113,10 +113,10 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
113
113
  .map((s) => s.rawMarkdown)
114
114
  );
115
115
 
116
- tag('substep').log(`Researching overlay: ${focusArea.name}`);
117
- const sectionMarkdown = await this._analyzeExpandedAction('', focusArea.name, diff, alreadyExpanded);
116
+ tag('substep').log(`Researching overlay: ${region.name}`);
117
+ const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
118
118
  if (!sectionMarkdown) {
119
- debugLog(`Overlay "${focusArea.name}" produced no meaningful expansion`);
119
+ debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
120
120
  return null;
121
121
  }
122
122
 
@@ -130,7 +130,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
130
130
  }
131
131
 
132
132
  saveResearch({ hash: pageStateHash }, updated);
133
- tag('substep').log(`Overlay research appended: ${focusArea.name}`);
133
+ tag('substep').log(`Overlay research appended: ${region.name}`);
134
134
  return sectionMarkdown;
135
135
  }
136
136
 
@@ -3,16 +3,7 @@ import { join } from 'node:path';
3
3
  import { parentPort } from 'node:worker_threads';
4
4
  import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
5
5
  import { isSamePageFamily } from '../../utils/url-matcher.ts';
6
-
7
- function diceSimilarity(a: Set<string>, b: Set<string>): number {
8
- let intersection = 0;
9
- for (const item of a) {
10
- if (b.has(item)) intersection++;
11
- }
12
- const total = a.size + b.size;
13
- if (total === 0) return 100;
14
- return Math.round(((2 * intersection) / total) * 100);
15
- }
6
+ import { diceSimilarity } from '../../utils/similarity.ts';
16
7
 
17
8
  parentPort!.on('message', (data: FingerprintRequest) => {
18
9
  const { html, statesDir, maxAgeMs, threshold, url } = data;
@@ -76,6 +76,7 @@ export class Researcher extends ResearcherBase implements Agent {
76
76
  }
77
77
 
78
78
  static getCachedResearch(state: WebPageState): string {
79
+ if (state instanceof ActionResult) return getCachedResearch(state.baseHash);
79
80
  return getCachedResearch(ActionResult.fromState(state).baseHash);
80
81
  }
81
82
 
@@ -235,7 +236,7 @@ export class Researcher extends ResearcherBase implements Agent {
235
236
  const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
236
237
  await this.visuallyAnnotateElements({ containers });
237
238
  this.actionResult = await this.explorer.capture({ screenshot: true });
238
- const visualResult = await this.analyzeScreenshotForVisualProps();
239
+ const visualResult = await this.analyzeScreenshotForVisualProps().finally(() => this.removeVisualAnnotations());
239
240
  if (visualResult.elements.size > 0) {
240
241
  await this.mergeVisualData(result, visualResult.elements);
241
242
  result.parseLocators();
package/src/ai/rules.ts CHANGED
@@ -154,11 +154,11 @@ export const protectionRule = dedent`
154
154
 
155
155
  Pre-existing data on the page belongs to the application, not the test.
156
156
  Items that were not created inside the current test scenario must not be deleted, removed, emptied, reset, archived, or otherwise destroyed.
157
- 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.
157
+ 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.
158
158
 
159
159
  The resource that the current page URL represents is "under test".
160
160
  The test must not destroy the resource it is running against — doing so invalidates every subsequent scenario that starts on the same URL.
161
- 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.
161
+ 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.
162
162
  </important>
163
163
  `;
164
164
 
@@ -174,7 +174,7 @@ export const dataProtectionRules = dedent`
174
174
  filter, tab, or list-inspection constraint. Use visible existing data when it is available.
175
175
  If no suitable data exists, report the missing precondition instead of creating data.
176
176
 
177
- Destructive actions are allowed only against disposable data created by the current scenario
177
+ Destructive actions are allowed only against data created by the current scenario
178
178
  or prepared for that scenario by Fisherman/API preconditions. Existing application data must
179
179
  remain unchanged.
180
180
  </data_protection_rules>
@@ -335,6 +335,14 @@ export const actionRule = dedent`
335
335
  If locator doesn't work, try CSS or XPath locators.
336
336
  If nothing works, use I.clickXY(x, y) as last resort.
337
337
 
338
+ When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
339
+ Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
340
+
341
+ <example>
342
+ I.click('Remove', step.opts({ elementIndex: 2 }));
343
+ I.click({ role: 'link', text: 'Details' }, '.panel', step.opts({ elementIndex: 1 }));
344
+ </example>
345
+
338
346
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
339
347
 
340
348
 
package/src/ai/tester.ts CHANGED
@@ -548,17 +548,17 @@ export class Tester extends TaskAgent implements Agent {
548
548
  const isNewUrl = this.previousUrl !== currentUrl;
549
549
  const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
550
550
 
551
- if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
551
+ if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.isOpen) {
552
552
  this.regionTransitioned = true;
553
553
  }
554
- this.previousRegionPresent = currentState.overlay.present;
554
+ this.previousRegionPresent = currentState.overlay.isOpen;
555
555
 
556
556
  this.previousUrl = currentUrl;
557
557
  this.previousStateHash = currentStateHash;
558
558
 
559
559
  let context = '';
560
560
 
561
- const focusArea = currentState.overlay;
561
+ const region = currentState.overlay;
562
562
 
563
563
  const focusedElement = currentState.focusedElement;
564
564
  if (focusedElement) {
@@ -577,29 +577,29 @@ export class Tester extends TaskAgent implements Agent {
577
577
  `;
578
578
  }
579
579
 
580
- if (focusArea.detected) {
581
- const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
580
+ if (region.isModal) {
581
+ const areaName = region.name ? ` "${region.name}"` : '';
582
582
  let rootHint = '';
583
- if (focusArea.root) rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
583
+ if (region.root) rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
584
584
  context += dedent`
585
- <focus_scope>
586
- A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
587
- Scope all interactions to elements inside this ${focusArea.type}.
588
- 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}.
589
- Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
590
- </focus_scope>
585
+ <overlay>
586
+ An overlay${areaName} is currently open above the page.${rootHint}
587
+ Scope all interactions to elements inside this overlay.
588
+ 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.
589
+ Use <page_aria> to confirm the element you target is actually inside the overlay.
590
+ </overlay>
591
591
  `;
592
592
  }
593
593
 
594
- if (!focusArea.detected && focusArea.present && isNewState) {
594
+ if (!region.isModal && region.isOpen && isNewState) {
595
595
  let rootHint = '';
596
- if (focusArea.root) rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
596
+ if (region.root) rootHint = `\nIt lives inside \`${region.root}\`.`;
597
597
  context += dedent`
598
- <area_of_interest>
599
- A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
598
+ <region>
599
+ A large new region "${region.name || 'unnamed region'}" appeared on this page without navigation.${rootHint}
600
600
  The scenario most likely continues inside this area — prefer its elements for your next actions.
601
601
  The rest of the page (navigation, menus, filters) is still interactive and remains available.
602
- </area_of_interest>
602
+ </region>
603
603
  `;
604
604
  }
605
605
 
@@ -663,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
663
663
  return context;
664
664
  }
665
665
 
666
- if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
666
+ if (region.isOpen && region.name && this.pageStateHash && this.pageActionResult) {
667
667
  const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
668
668
  if (overlaySection) {
669
669
  context += dedent`
package/src/ai/tools.ts CHANGED
@@ -15,7 +15,6 @@ import { pause } from '../utils/loop.js';
15
15
  import { WebElement } from '../utils/web-element.ts';
16
16
  import type { ToolDeps } from './agent.ts';
17
17
  import { Navigator } from './navigator.ts';
18
- import type { AIProvider } from './provider.ts';
19
18
  import { Researcher } from './researcher.ts';
20
19
  import { sectionContextRule } from './rules.ts';
21
20
  import { isInteractive } from './task-agent.ts';
@@ -31,7 +30,7 @@ interface AgentToolDeps extends ToolDeps {
31
30
 
32
31
  export const ASSERTION_TOOLS = ['verify'] as const;
33
32
 
34
- export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, task: Task) {
33
+ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task) {
35
34
  return {
36
35
  click: tool({
37
36
  description: dedent`
@@ -44,6 +43,12 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
44
43
  Container narrows search area. Use when page has multiple matching elements.
45
44
  Example: Page has 3 "Delete" buttons in different rows:
46
45
  I.click("Delete", ".row-1") - clicks Delete inside element with class row-1
46
+ I.click(locator, step.opts({ elementIndex: N })) - click the Nth element the locator matches
47
+ Use after a result reported several matches. N is the "Element N" number from that list.
48
+ step.opts() always goes LAST, after the container when there is one.
49
+ Example: the result listed 3 "Delete" buttons and you want the second:
50
+ I.click("Delete", step.opts({ elementIndex: 2 }))
51
+ I.click("Delete", ".table", step.opts({ elementIndex: 2 })) - same, narrowed to a container
47
52
 
48
53
  IMPORTANT: This tool ONLY accepts click commands. For typing text, use form() tool.
49
54
  CRITICAL: All commands MUST target the SAME element using different locators.
@@ -62,6 +67,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
62
67
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
63
68
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
64
69
  5. I.clickXY(x, y) - coordinates fallback
70
+ After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
65
71
  `),
66
72
  explanation: z.string().describe('Why you are clicking this element'),
67
73
  }),
@@ -91,6 +97,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
91
97
  const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
92
98
  const action = explorer.action();
93
99
  const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
100
+ let ambiguityError: Error | null = null;
94
101
 
95
102
  for (let i = 0; i < commands.length; i++) {
96
103
  const command = transformContainsCommand(commands[i]);
@@ -100,6 +107,8 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
100
107
  if (action.lastError) attempt.error = errorText(action.lastError);
101
108
  attempts.push(attempt);
102
109
 
110
+ if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
111
+
103
112
  if (success) {
104
113
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
105
114
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
@@ -107,31 +116,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
107
116
  }
108
117
  }
109
118
 
110
- let disambiguated = null;
111
- if (attempts.some((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
112
- disambiguated = await disambiguateElements(action.lastError, explanation, ai);
113
- }
114
-
115
- if (disambiguated) {
116
- debugLog('Disambiguation picked element %d', disambiguated.position);
117
- const failedCommand = attempts.find((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))?.command;
118
- const retryCommands = [];
119
- if (failedCommand) {
120
- retryCommands.push(failedCommand.replace(/\)$/, `, step.opts({ elementIndex: ${disambiguated.position} }))`));
121
- }
122
- retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
123
-
124
- for (const retryCmd of retryCommands) {
125
- if (!(await action.attempt(retryCmd, explanation))) {
126
- attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
127
- continue;
128
- }
129
- const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd);
130
- await commitNote(activeNote, TestResult.PASSED, toolResult, action);
131
- return successToolResult('click', { ...toolResult, attempts, code: retryCmd, disambiguated: true }, action);
132
- }
133
- }
134
-
135
119
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
136
120
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
137
121
 
@@ -145,7 +129,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
145
129
  attempts,
146
130
  suggestion,
147
131
  },
148
- action.lastError
132
+ ambiguityError || action.lastError
149
133
  );
150
134
  },
151
135
  }),
@@ -413,13 +397,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
413
397
  const message = errorText(action.lastError);
414
398
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
415
399
 
416
- let formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
417
- if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
418
- const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
419
- if (disambiguated) {
420
- formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
421
- }
422
- }
400
+ const formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
423
401
 
424
402
  return failedToolResult(
425
403
  'form',
@@ -1284,13 +1262,11 @@ export async function failedToolResult(action: string, message: string, data?: R
1284
1262
 
1285
1263
  function getMultipleElementsSuggestion(): string {
1286
1264
  return dedent`
1287
- Multiple elements matched your locator. To fix this:
1288
- 1. Use container context: I.click({ "role": "button", "text": "Submit" }, '.form-container')
1289
- 2. Use more specific CSS: target the actual element (input, button, a) not wrapper divs
1290
- 3. Add distinguishing attributes: input[type="submit"], button[type="submit"], [value="..."]
1291
- 4. If buttons have similar text like "Create" and "Create Demo", use the FULL unique text
1292
- 5. Use xpathCheck() to inspect matched elements and pick the correct one
1293
- 6. Use visualClick() to click the right element by visual appearance
1265
+ Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1266
+ Read the numbered elements list and click the one you meant by its number:
1267
+ reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1268
+ If none of them is the element you want, narrow the locator with a container or its full unique text.
1269
+ If the list is missing, call xpathCheck() to see what the locator matches.
1294
1270
  `;
1295
1271
  }
1296
1272
 
@@ -1367,50 +1343,6 @@ export async function formatMatchedElements(error: Error | null | undefined): Pr
1367
1343
  return formatElementList(details);
1368
1344
  }
1369
1345
 
1370
- async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
1371
- const elementDetails = await extractWebElements(error);
1372
- if (!elementDetails) return null;
1373
-
1374
- const elementList = formatElementList(elementDetails);
1375
-
1376
- const schema = z.object({
1377
- position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
1378
- });
1379
-
1380
- try {
1381
- const result = await provider.generateObject(
1382
- [
1383
- {
1384
- role: 'user' as const,
1385
- content: dedent`
1386
- A click action failed because multiple elements matched the locator.
1387
- The intended action was: ${explanation}
1388
-
1389
- Here are the matched elements:
1390
-
1391
- ${elementList}
1392
-
1393
- Which element (1-${elementDetails.length}) best matches the intended action?
1394
- Return the position number, or null if none of them match.
1395
- `,
1396
- },
1397
- ],
1398
- schema,
1399
- provider.getModelForAgent(),
1400
- { agentName: 'disambiguator', timeout: 15000 }
1401
- );
1402
-
1403
- const position = result?.object?.position;
1404
- if (position && position >= 1 && position <= elementDetails.length) {
1405
- return { position, xpath: elementDetails[position - 1].xpath };
1406
- }
1407
- return null;
1408
- } catch (e) {
1409
- debugLog('Element disambiguation AI call failed: %s', e);
1410
- return null;
1411
- }
1412
- }
1413
-
1414
1346
  function getNotFoundSuggestion(errorMessage: string): string | null {
1415
1347
  if (!errorMessage.includes('not found')) {
1416
1348
  return null;
@@ -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.ts';
3
+ import { generalizeUrl, isDynamicSegment } from '../utils/url-matcher.ts';
4
4
  import { RequestResult } from './request-result.ts';
5
5
 
6
6
  const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
@@ -46,10 +46,6 @@ export class RequestStore {
46
46
  result.save(this.outputDir);
47
47
  }
48
48
 
49
- addRequest(result: RequestResult): void {
50
- this.addMadeRequest(result);
51
- }
52
-
53
49
  getCapturedRequests(): RequestResult[] {
54
50
  return this.capturedRequests;
55
51
  }
@@ -58,27 +54,10 @@ export class RequestStore {
58
54
  return this.madeRequests;
59
55
  }
60
56
 
61
- getRequests(): RequestResult[] {
62
- return this.madeRequests;
63
- }
64
-
65
57
  getLastRequest(): RequestResult | undefined {
66
58
  return this.madeRequests[this.madeRequests.length - 1];
67
59
  }
68
60
 
69
- getRequestsByEndpoint(pathPrefix: string): RequestResult[] {
70
- return this.madeRequests.filter((r) => r.path.startsWith(pathPrefix));
71
- }
72
-
73
- getRequestsByMethod(method: string): RequestResult[] {
74
- const upper = method.toUpperCase();
75
- return this.madeRequests.filter((r) => r.method === upper);
76
- }
77
-
78
- getRequestsByStatus(status: number): RequestResult[] {
79
- return this.madeRequests.filter((r) => r.status === status);
80
- }
81
-
82
61
  toEndpointList(scopePath?: string): string {
83
62
  let requests = this.capturedRequests;
84
63
  if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
@@ -87,7 +66,7 @@ export class RequestStore {
87
66
  const lines: string[] = [];
88
67
 
89
68
  for (const req of requests) {
90
- const key = `${req.method} ${normalizePathPattern(req.path)}`;
69
+ const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`;
91
70
  if (seen.has(key)) continue;
92
71
  seen.add(key);
93
72
  lines.push(key);
@@ -113,14 +92,18 @@ export class RequestStore {
113
92
 
114
93
  findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
115
94
  const upper = method.toUpperCase();
116
- const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
95
+ const search = generalizeUrl(searchPath, () => '{id}')
96
+ .split('/')
97
+ .filter(Boolean);
117
98
 
118
99
  let best: RequestResult | undefined;
119
100
  let bestScore = -1;
120
101
 
121
102
  for (const req of this.capturedRequests) {
122
103
  if (req.method !== upper) continue;
123
- const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
104
+ const segments = generalizeUrl(req.path, () => '{id}')
105
+ .split('/')
106
+ .filter(Boolean);
124
107
  if (segments.length < search.length) continue;
125
108
  if (!search.every((segment, i) => segment === segments[i])) continue;
126
109
 
@@ -159,8 +142,7 @@ export class RequestStore {
159
142
  }
160
143
 
161
144
  getWriteRequestsForScope(scopePath: string): RequestResult[] {
162
- const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
163
- const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
145
+ const writes = this.capturedRequests.filter((r) => r.isWrite);
164
146
  const scopeSegments = scopePath.split('/').filter(Boolean);
165
147
  if (scopeSegments.length === 0) return writes;
166
148
 
@@ -191,6 +173,10 @@ export class RequestStore {
191
173
  }
192
174
  }
193
175
 
176
+ export function isFailedRequest(request: RequestResult): boolean {
177
+ return request.status >= 400 || Boolean(request.error);
178
+ }
179
+
194
180
  function normalizePathPattern(urlPath: string): string {
195
181
  return urlPath
196
182
  .split('/')
@@ -6,7 +6,7 @@ import { tag } from '../utils/logger.ts';
6
6
 
7
7
  export function validateSpecs(specs?: string[]): void {
8
8
  if (!specs?.length) {
9
- throw new Error('API spec is required. Set api.spec in your config file.');
9
+ throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
10
10
  }
11
11
  }
12
12
 
@@ -5,6 +5,7 @@ import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, c
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
 
@@ -37,7 +38,7 @@ export class ConfigCommand extends BaseCommand {
37
38
  const dirs: Record<string, string> = {};
38
39
  if (options.root) {
39
40
  for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
40
- dirs[name] = path.join(options.root, dir);
41
+ dirs[name] = path.resolve(options.root, dir);
41
42
  }
42
43
  }
43
44
 
@@ -75,13 +76,7 @@ export class ConfigCommand extends BaseCommand {
75
76
  if (options.json) return JSON.stringify(data, null, 2);
76
77
 
77
78
  const lines: string[] = [];
78
- const section = (title: string, entries: [string, string][]) => {
79
- if (!entries.length) return;
80
- const width = Math.max(...entries.map(([label]) => label.length));
81
- lines.push(chalk.bold(title));
82
- for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
83
- lines.push('');
84
- };
79
+ const section = (title: string, entries: [string, string][]) => lines.push(...renderSection(title, entries));
85
80
 
86
81
  const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
87
82
  if (data.url) general.push(['url', data.url]);
@@ -26,7 +26,7 @@ export class DrillCommand extends BaseCommand {
26
26
  }
27
27
 
28
28
  private parseKnowledgeArg(args: string): string | undefined {
29
- const match = args.match(/--knowledge\s+(\S+)/);
29
+ const match = args.match(/--save-knowledge\s+(\S+)/);
30
30
  return match ? match[1] : undefined;
31
31
  }
32
32