explorbot 0.4.1 → 0.4.3

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 (141) hide show
  1. package/bin/explorbot-cli.ts +14 -4
  2. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  5. package/boat/doc-collector/src/ai/tools.ts +1 -1
  6. package/boat/doc-collector/src/cli.ts +18 -2
  7. package/boat/doc-collector/src/config.ts +3 -0
  8. package/boat/doc-collector/src/docbot.ts +56 -11
  9. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  10. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  11. package/boat/doc-collector/src/screenshots.ts +106 -3
  12. package/boat/doc-collector/src/template-dedup.ts +31 -0
  13. package/boat/prima/src/cli.ts +3 -0
  14. package/boat/prima/src/prima.ts +3 -4
  15. package/dist/bin/explorbot-cli.js +14 -4
  16. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  17. package/dist/boat/api-tester/src/cli.js +2 -0
  18. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  19. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  20. package/dist/boat/doc-collector/src/cli.js +15 -2
  21. package/dist/boat/doc-collector/src/config.js +1 -0
  22. package/dist/boat/doc-collector/src/docbot.js +52 -10
  23. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  24. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  25. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  26. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  27. package/dist/boat/prima/src/cli.js +2 -0
  28. package/dist/boat/prima/src/prima.js +3 -4
  29. package/dist/package.json +1 -1
  30. package/dist/src/action-result.d.ts +2 -2
  31. package/dist/src/action-result.js +15 -17
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +15 -15
  34. package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
  35. package/dist/src/ai/fisherman/request-haul.js +31 -0
  36. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  37. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
  38. package/dist/src/ai/fisherman.d.ts +14 -4
  39. package/dist/src/ai/fisherman.js +96 -18
  40. package/dist/src/ai/pilot.d.ts +13 -1
  41. package/dist/src/ai/pilot.js +40 -22
  42. package/dist/src/ai/provider.js +8 -14
  43. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  44. package/dist/src/ai/researcher/coordinates.js +13 -1
  45. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  46. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  47. package/dist/src/ai/researcher.js +1 -1
  48. package/dist/src/ai/rules.js +10 -0
  49. package/dist/src/ai/tester.js +20 -20
  50. package/dist/src/ai/tools.d.ts +1 -1
  51. package/dist/src/ai/tools.js +18 -74
  52. package/dist/src/api/request-result.js +3 -1
  53. package/dist/src/api/request-store.d.ts +7 -6
  54. package/dist/src/api/request-store.js +65 -37
  55. package/dist/src/api/xhr-capture.d.ts +2 -0
  56. package/dist/src/api/xhr-capture.js +35 -10
  57. package/dist/src/commands/config-command.js +2 -9
  58. package/dist/src/commands/help-json-command.d.ts +31 -0
  59. package/dist/src/commands/help-json-command.js +58 -0
  60. package/dist/src/commands/index.js +2 -0
  61. package/dist/src/commands/init-command.js +3 -7
  62. package/dist/src/commands/plans-command.d.ts +4 -1
  63. package/dist/src/commands/plans-command.js +4 -4
  64. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  65. package/dist/src/commands/recommended-models-command.js +85 -0
  66. package/dist/src/config.d.ts +2 -0
  67. package/dist/src/config.js +7 -2
  68. package/dist/src/experience-tracker.js +1 -1
  69. package/dist/src/explorbot.js +3 -3
  70. package/dist/src/global-config.d.ts +2 -0
  71. package/dist/src/global-config.js +6 -0
  72. package/dist/src/state-manager.d.ts +3 -3
  73. package/dist/src/state-manager.js +8 -8
  74. package/dist/src/test-plan.d.ts +1 -0
  75. package/dist/src/test-plan.js +13 -9
  76. package/dist/src/utils/aria.d.ts +1 -1
  77. package/dist/src/utils/aria.js +23 -2
  78. package/dist/src/utils/cli-section.d.ts +1 -0
  79. package/dist/src/utils/cli-section.js +12 -0
  80. package/dist/src/utils/html-diff.d.ts +1 -0
  81. package/dist/src/utils/html-diff.js +5 -5
  82. package/dist/src/utils/html.d.ts +2 -0
  83. package/dist/src/utils/html.js +5 -4
  84. package/dist/src/utils/overlay.d.ts +19 -55
  85. package/dist/src/utils/overlay.js +94 -148
  86. package/dist/src/utils/region.d.ts +41 -0
  87. package/dist/src/utils/region.js +99 -0
  88. package/dist/src/utils/similarity.d.ts +1 -0
  89. package/dist/src/utils/similarity.js +11 -0
  90. package/dist/src/utils/url-matcher.d.ts +1 -1
  91. package/dist/src/utils/url-matcher.js +4 -4
  92. package/docs/doc-collection/basics.md +11 -5
  93. package/docs/doc-collection/crawling.md +21 -1
  94. package/docs/reference/commands.md +4 -0
  95. package/docs/reference/configuration.md +4 -0
  96. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  97. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  98. package/docs/workflow/agentic-usage.md +12 -0
  99. package/docs/workflow/application-spec.md +4 -0
  100. package/package.json +1 -1
  101. package/src/action-result.ts +15 -16
  102. package/src/action.ts +16 -15
  103. package/src/ai/fisherman/request-haul.ts +36 -0
  104. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
  105. package/src/ai/fisherman.ts +110 -20
  106. package/src/ai/pilot.ts +38 -20
  107. package/src/ai/provider.ts +8 -12
  108. package/src/ai/researcher/coordinates.ts +13 -1
  109. package/src/ai/researcher/deep-analysis.ts +9 -9
  110. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  111. package/src/ai/researcher.ts +1 -1
  112. package/src/ai/rules.ts +10 -0
  113. package/src/ai/tester.ts +18 -18
  114. package/src/ai/tools.ts +18 -86
  115. package/src/api/request-result.ts +2 -1
  116. package/src/api/request-store.ts +68 -42
  117. package/src/api/xhr-capture.ts +39 -11
  118. package/src/commands/config-command.ts +2 -7
  119. package/src/commands/help-json-command.ts +74 -0
  120. package/src/commands/index.ts +2 -0
  121. package/src/commands/init-command.ts +4 -11
  122. package/src/commands/plans-command.ts +5 -5
  123. package/src/commands/recommended-models-command.ts +92 -0
  124. package/src/config.ts +9 -2
  125. package/src/experience-tracker.ts +1 -1
  126. package/src/explorbot.ts +3 -3
  127. package/src/global-config.ts +8 -0
  128. package/src/state-manager.ts +10 -9
  129. package/src/test-plan.ts +12 -10
  130. package/src/utils/aria.ts +26 -3
  131. package/src/utils/cli-section.ts +12 -0
  132. package/src/utils/html-diff.ts +6 -6
  133. package/src/utils/html.ts +6 -4
  134. package/src/utils/overlay.ts +105 -192
  135. package/src/utils/region.ts +132 -0
  136. package/src/utils/similarity.ts +9 -0
  137. package/src/utils/url-matcher.ts +3 -3
  138. package/dist/src/ai/fisherman-tools.d.ts +0 -138
  139. package/dist/src/utils/request-map.d.ts +0 -7
  140. package/dist/src/utils/request-map.js +0 -16
  141. package/src/utils/request-map.ts +0 -19
package/src/ai/pilot.ts CHANGED
@@ -18,6 +18,7 @@ import { truncateJson } from '../utils/strings.ts';
18
18
  import type { Agent, AgentDeps } from './agent.ts';
19
19
  import type { Conversation } from './conversation.ts';
20
20
  import type { Fisherman } from './fisherman.ts';
21
+ import { createAskApiTool } from './fisherman/tools.ts';
21
22
  import type { Navigator } from './navigator.ts';
22
23
  import type { Provider } from './provider.ts';
23
24
  import type { Researcher } from './researcher.ts';
@@ -31,6 +32,7 @@ const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
31
32
  const PILOT_REASONING_LIMIT = 500;
32
33
  const PILOT_MESSAGE_LIMIT = 2;
33
34
  const PILOT_MESSAGE_MAX_LENGTH = 160;
35
+ const PILOT_REQUEST_LIMIT = 5;
34
36
 
35
37
  export class Pilot implements Agent {
36
38
  emoji = '🧭';
@@ -451,7 +453,8 @@ export class Pilot implements Agent {
451
453
 
452
454
  Plan the test execution for this scenario.
453
455
 
454
- FIRST: Decide if precondition() is needed.
456
+ FIRST: Decide if precondition() is needed. When the page does not settle whether suitable data
457
+ already exists, call askApi() to find out before creating any.
455
458
 
456
459
  Call precondition() WHEN:
457
460
  - The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
@@ -687,7 +690,7 @@ export class Pilot implements Agent {
687
690
  }
688
691
  this.conversation!.addUserText(finalUserText);
689
692
 
690
- const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
693
+ const tools = { ...this.pickPlanningTools(), ...this.buildFishermanTools(opts.task) };
691
694
 
692
695
  const result = await this.provider.invokeConversation(this.conversation!, tools, {
693
696
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
@@ -733,7 +736,12 @@ export class Pilot implements Agent {
733
736
  return planning;
734
737
  }
735
738
 
736
- private buildPreconditionTool(task: Test) {
739
+ private fishermanStatus(): string {
740
+ if (this.fisherman?.isAvailable()) return 'available';
741
+ return 'none';
742
+ }
743
+
744
+ private buildFishermanTools(task: Test) {
737
745
  const unavailable = 'Data was not created and cannot be created automatically. Do not call precondition again for this test — continue with what the page already shows.';
738
746
  return {
739
747
  precondition: tool({
@@ -744,7 +752,7 @@ export class Pilot implements Agent {
744
752
  execute: async ({ description }) => {
745
753
  task.addNote(`Precondition: ${description}`);
746
754
  tag('info').log(`Precondition: ${description}`);
747
- debugLog(`precondition: ${description}, fisherman: ${this.fisherman?.isAvailable() ? 'available' : 'none'}`);
755
+ debugLog(`precondition: ${description}, fisherman: ${this.fishermanStatus()}`);
748
756
 
749
757
  if (!this.fisherman || !this.fisherman.isAvailable()) {
750
758
  const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
@@ -775,6 +783,7 @@ export class Pilot implements Agent {
775
783
  return { noted: true, prepared: true, created: result.created };
776
784
  },
777
785
  }),
786
+ ...createAskApiTool(this.fisherman, task),
778
787
  };
779
788
  }
780
789
 
@@ -826,17 +835,17 @@ export class Pilot implements Agent {
826
835
  lines.push(`h3: ${state.h3 || ''}`);
827
836
  lines.push(`h4: ${state.h4 || ''}`);
828
837
 
829
- const focusArea = state.overlay;
830
- if (focusArea.detected) {
831
- let line = `modal: ${focusArea.name || focusArea.type}`;
832
- if (focusArea.root) line += ` (root: ${focusArea.root})`;
838
+ const region = state.overlay;
839
+ if (region.isModal) {
840
+ let line = `overlay: ${region.name || region.type}`;
841
+ if (region.root) line += ` (root: ${region.root})`;
833
842
  lines.push(line);
834
- } else if (focusArea.present) {
835
- let line = `region: ${focusArea.name || 'unnamed'} (inline`;
836
- if (focusArea.root) line += `, root: ${focusArea.root}`;
843
+ } else if (region.isOpen) {
844
+ let line = `region: ${region.name || 'unnamed'} (inline`;
845
+ if (region.root) line += `, root: ${region.root}`;
837
846
  lines.push(`${line})`);
838
847
  } else {
839
- lines.push('modal: none');
848
+ lines.push('overlay: none');
840
849
  }
841
850
 
842
851
  const tabs = this.stateManager.otherTabs;
@@ -1071,9 +1080,13 @@ export class Pilot implements Agent {
1071
1080
 
1072
1081
  if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
1073
1082
 
1074
- const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r: any) => r.status >= 400);
1075
- if (failedRequests.length > 0) {
1076
- line += `\n requests: ${failedRequests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
1083
+ const pageRequests = t.output?.pageDiff?.requests ?? [];
1084
+ const requests = pageRequests
1085
+ .filter((r: any) => r.status >= 400)
1086
+ .concat(pageRequests.filter((r: any) => r.status < 400))
1087
+ .slice(0, PILOT_REQUEST_LIMIT);
1088
+ if (requests.length > 0) {
1089
+ line += `\n requests: ${requests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
1077
1090
  }
1078
1091
 
1079
1092
  const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
@@ -1139,15 +1152,15 @@ export class Pilot implements Agent {
1139
1152
 
1140
1153
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1141
1154
  - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1142
- - "modal: none" but Tester targets a modalmodal closed; re-trigger.
1155
+ - "overlay: none" but Tester targets an overlayoverlay closed; re-trigger.
1143
1156
  - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1144
1157
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
1145
- - MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
1158
+ - MultipleElementsFound → nothing was clicked. Tell Tester to reuse the same locator with step.opts({ elementIndex: N }) from the numbered elements list.
1146
1159
  - Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
1147
1160
  - Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
1148
1161
  - form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
1149
1162
  - ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
1150
- - Empty dropdown/list when items expected → missing data; call precondition() to create it.
1163
+ - Empty dropdown/list when items expected → wait explicitly, then check the state changed: ariaDiff and any GET that loaded data. If still nothing loaded, confirm the empty state with verify(), or askApi() for whether the data exists at all.
1151
1164
  - Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
1152
1165
  - Multi-action explanation in one tool call → instruct Tester to split.
1153
1166
 
@@ -1163,8 +1176,13 @@ export class Pilot implements Agent {
1163
1176
 
1164
1177
  ${capabilityGroundingRule}
1165
1178
 
1166
- YOUR Pilot-only tool: precondition(description) create FRESH disposable test data via API. Never
1167
- request users. Use when:
1179
+ YOUR Pilot-only tools, both over the API:
1180
+
1181
+ askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
1182
+ suitable data is already there before creating any, and to get the exact name or id of an existing
1183
+ record a step must act on.
1184
+
1185
+ precondition(description) — create FRESH disposable test data. Never request users. Use when:
1168
1186
 
1169
1187
  - Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
1170
1188
  - Scenario needs auxiliary data (labels, categories, statuses for filtering).
@@ -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;
@@ -236,7 +236,7 @@ export class Researcher extends ResearcherBase implements Agent {
236
236
  const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
237
237
  await this.visuallyAnnotateElements({ containers });
238
238
  this.actionResult = await this.explorer.capture({ screenshot: true });
239
- const visualResult = await this.analyzeScreenshotForVisualProps();
239
+ const visualResult = await this.analyzeScreenshotForVisualProps().finally(() => this.removeVisualAnnotations());
240
240
  if (visualResult.elements.size > 0) {
241
241
  await this.mergeVisualData(result, visualResult.elements);
242
242
  result.parseLocators();
package/src/ai/rules.ts CHANGED
@@ -173,6 +173,8 @@ export const dataProtectionRules = dedent`
173
173
  Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
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
+ Reading through the API to establish what already exists is not a mutation and stays allowed
177
+ under a read-only constraint.
176
178
 
177
179
  Destructive actions are allowed only against data created by the current scenario
178
180
  or prepared for that scenario by Fisherman/API preconditions. Existing application data must
@@ -335,6 +337,14 @@ export const actionRule = dedent`
335
337
  If locator doesn't work, try CSS or XPath locators.
336
338
  If nothing works, use I.clickXY(x, y) as last resort.
337
339
 
340
+ When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
341
+ Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
342
+
343
+ <example>
344
+ I.click('Remove', step.opts({ elementIndex: 2 }));
345
+ I.click({ role: 'link', text: 'Details' }, '.panel', step.opts({ elementIndex: 1 }));
346
+ </example>
347
+
338
348
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
339
349
 
340
350
 
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;
@@ -124,7 +124,8 @@ export class RequestResult {
124
124
  }
125
125
 
126
126
  writeFileSync(this.requestFile, yaml, 'utf8');
127
- writeFileSync(this.responseFile, this._rawResponseBody || '', 'utf8');
127
+ if (!this._rawResponseBody) return;
128
+ writeFileSync(this.responseFile, this._rawResponseBody, 'utf8');
128
129
  }
129
130
 
130
131
  static load(requestFile: string): RequestResult {