explorbot 0.4.1 → 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 (128) hide show
  1. package/bin/explorbot-cli.ts +8 -3
  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 +9 -3
  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 +11 -0
  35. package/dist/src/ai/fisherman/request-haul.js +28 -0
  36. package/dist/src/ai/fisherman-tools.d.ts +10 -1
  37. package/dist/src/ai/fisherman-tools.js +35 -31
  38. package/dist/src/ai/fisherman.d.ts +3 -2
  39. package/dist/src/ai/fisherman.js +8 -6
  40. package/dist/src/ai/pilot.js +21 -16
  41. package/dist/src/ai/provider.js +8 -14
  42. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  43. package/dist/src/ai/researcher/coordinates.js +13 -1
  44. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  45. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  46. package/dist/src/ai/researcher.js +1 -1
  47. package/dist/src/ai/rules.js +8 -0
  48. package/dist/src/ai/tester.js +20 -20
  49. package/dist/src/ai/tools.d.ts +1 -1
  50. package/dist/src/ai/tools.js +18 -74
  51. package/dist/src/api/request-store.d.ts +1 -5
  52. package/dist/src/api/request-store.js +12 -22
  53. package/dist/src/commands/config-command.js +2 -9
  54. package/dist/src/commands/index.js +2 -0
  55. package/dist/src/commands/init-command.js +3 -7
  56. package/dist/src/commands/plans-command.d.ts +4 -1
  57. package/dist/src/commands/plans-command.js +4 -4
  58. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  59. package/dist/src/commands/recommended-models-command.js +85 -0
  60. package/dist/src/config.d.ts +2 -0
  61. package/dist/src/config.js +7 -2
  62. package/dist/src/experience-tracker.js +1 -1
  63. package/dist/src/explorbot.js +3 -3
  64. package/dist/src/global-config.d.ts +2 -0
  65. package/dist/src/global-config.js +6 -0
  66. package/dist/src/state-manager.d.ts +3 -3
  67. package/dist/src/state-manager.js +8 -8
  68. package/dist/src/test-plan.d.ts +1 -0
  69. package/dist/src/test-plan.js +13 -9
  70. package/dist/src/utils/aria.d.ts +1 -1
  71. package/dist/src/utils/aria.js +23 -2
  72. package/dist/src/utils/cli-section.d.ts +1 -0
  73. package/dist/src/utils/cli-section.js +12 -0
  74. package/dist/src/utils/html-diff.d.ts +1 -0
  75. package/dist/src/utils/html-diff.js +5 -5
  76. package/dist/src/utils/html.d.ts +2 -0
  77. package/dist/src/utils/html.js +5 -4
  78. package/dist/src/utils/overlay.d.ts +19 -55
  79. package/dist/src/utils/overlay.js +94 -148
  80. package/dist/src/utils/region.d.ts +41 -0
  81. package/dist/src/utils/region.js +99 -0
  82. package/dist/src/utils/similarity.d.ts +1 -0
  83. package/dist/src/utils/similarity.js +11 -0
  84. package/dist/src/utils/url-matcher.d.ts +1 -1
  85. package/dist/src/utils/url-matcher.js +4 -4
  86. package/docs/doc-collection/basics.md +11 -5
  87. package/docs/doc-collection/crawling.md +21 -1
  88. package/docs/reference/commands.md +3 -0
  89. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  90. package/docs/workflow/application-spec.md +4 -0
  91. package/package.json +1 -1
  92. package/src/action-result.ts +15 -16
  93. package/src/action.ts +16 -15
  94. package/src/ai/fisherman/request-haul.ts +32 -0
  95. package/src/ai/fisherman-tools.ts +38 -32
  96. package/src/ai/fisherman.ts +8 -7
  97. package/src/ai/pilot.ts +19 -14
  98. package/src/ai/provider.ts +8 -12
  99. package/src/ai/researcher/coordinates.ts +13 -1
  100. package/src/ai/researcher/deep-analysis.ts +9 -9
  101. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  102. package/src/ai/researcher.ts +1 -1
  103. package/src/ai/rules.ts +8 -0
  104. package/src/ai/tester.ts +18 -18
  105. package/src/ai/tools.ts +18 -86
  106. package/src/api/request-store.ts +13 -27
  107. package/src/commands/config-command.ts +2 -7
  108. package/src/commands/index.ts +2 -0
  109. package/src/commands/init-command.ts +4 -11
  110. package/src/commands/plans-command.ts +5 -5
  111. package/src/commands/recommended-models-command.ts +92 -0
  112. package/src/config.ts +9 -2
  113. package/src/experience-tracker.ts +1 -1
  114. package/src/explorbot.ts +3 -3
  115. package/src/global-config.ts +8 -0
  116. package/src/state-manager.ts +10 -9
  117. package/src/test-plan.ts +12 -10
  118. package/src/utils/aria.ts +26 -3
  119. package/src/utils/cli-section.ts +12 -0
  120. package/src/utils/html-diff.ts +6 -6
  121. package/src/utils/html.ts +6 -4
  122. package/src/utils/overlay.ts +105 -192
  123. package/src/utils/region.ts +132 -0
  124. package/src/utils/similarity.ts +9 -0
  125. package/src/utils/url-matcher.ts +3 -3
  126. package/dist/src/utils/request-map.d.ts +0 -7
  127. package/dist/src/utils/request-map.js +0 -16
  128. package/src/utils/request-map.ts +0 -19
package/src/action.ts CHANGED
@@ -14,6 +14,7 @@ import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError }
14
14
  import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
15
15
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
16
  import { Overlay, OverlayPage } from './utils/overlay.js';
17
+ import type { Region } from './utils/region.js';
17
18
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
18
19
  import { safeFilename } from './utils/strings.ts';
19
20
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
@@ -181,7 +182,7 @@ class Action {
181
182
  focusedElement,
182
183
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
183
184
  });
184
- if (!frame) await this.detectRegionOfInterest(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
185
+ if (!frame) await this.detectRegion(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
185
186
  this.stateManager.updateState(result, codeBlock);
186
187
  return result;
187
188
  } catch (err) {
@@ -193,36 +194,36 @@ class Action {
193
194
  }
194
195
  }
195
196
 
196
- private async detectRegionOfInterest(result: ActionResult): Promise<void> {
197
+ private async detectRegion(result: ActionResult): Promise<void> {
197
198
  const previousState = this.stateManager.getCurrentState();
198
199
  if (!previousState) return;
199
200
  const previous = ActionResult.fromState(previousState);
200
- const previousOverlay = previous.overlay;
201
+ const previousRegion = previous.overlay;
201
202
  const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
202
203
  const overlayPage = new OverlayPage(this.playwrightHelper.page);
203
204
 
204
- if (result.overlay.detected && previousOverlay.detected && previousOverlay.root && previousOverlay.type === result.overlay.type && previousOverlay.name === result.overlay.name) {
205
- result.overlay = previousOverlay;
205
+ if (result.overlay.isModal && previousRegion.isModal && previousRegion.root && previousRegion.type === result.overlay.type && previousRegion.name === result.overlay.name) {
206
+ result.overlay = previousRegion;
206
207
  return;
207
208
  }
208
209
 
209
210
  if (!previous.html) return;
210
211
 
211
212
  if (previous.html === result.html) {
212
- if (sameUrl && previousOverlay.present && previousOverlay.xpath && !result.overlay.detected) result.overlay = previousOverlay;
213
+ if (sameUrl && previousRegion.isOpen && previousRegion.xpath && !result.overlay.isModal) result.overlay = previousRegion;
213
214
  return;
214
215
  }
215
216
 
216
- let carried: Overlay | null = null;
217
- if (sameUrl && previousOverlay.present && previousOverlay.xpath) {
218
- if (await overlayPage.isStillOpen(previousOverlay)) {
219
- carried = previousOverlay;
217
+ let carried: Region | null = null;
218
+ if (sameUrl && previousRegion.isOpen && previousRegion.xpath) {
219
+ if (await overlayPage.isStillOpen(previousRegion)) {
220
+ carried = previousRegion;
220
221
  } else {
221
- debugLog(`Region closed: ${previousOverlay.name || previousOverlay.type}`);
222
- if (!result.overlay.detected) {
223
- const parent = previousOverlay.parent;
222
+ debugLog(`Region closed: ${previousRegion.name || previousRegion.type}`);
223
+ if (!result.overlay.isModal) {
224
+ const parent = previousRegion.parent;
224
225
  if (parent?.xpath) {
225
- const restored = new Overlay(parent);
226
+ const restored = Overlay.resolve({ overlay: parent });
226
227
  if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
227
228
  }
228
229
  return;
@@ -239,7 +240,7 @@ class Action {
239
240
  previousHtml: previous.html,
240
241
  });
241
242
 
242
- if (result.overlay.detected) {
243
+ if (result.overlay instanceof Overlay) {
243
244
  if (detected) result.overlay = result.overlay.withGeometry(detected);
244
245
  return;
245
246
  }
@@ -0,0 +1,32 @@
1
+ import type { RequestResult } from '../../api/request-result.ts';
2
+ import { type RequestStore, isFailedRequest } from '../../api/request-store.ts';
3
+
4
+ export class RequestHaul {
5
+ private start: number;
6
+
7
+ constructor(private store: RequestStore) {
8
+ this.start = store.getMadeRequests().length;
9
+ }
10
+
11
+ requests(): RequestResult[] {
12
+ return this.store.getMadeRequests().slice(this.start);
13
+ }
14
+
15
+ failed(): RequestResult[] {
16
+ return this.requests().filter(isFailedRequest);
17
+ }
18
+
19
+ successfulWrites(): RequestResult[] {
20
+ return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
21
+ }
22
+
23
+ byId(): Map<string, RequestResult> {
24
+ const map = new Map<string, RequestResult>();
25
+ for (const request of this.successfulWrites()) {
26
+ const { id } = request.extractIdAndTitle();
27
+ if (id === undefined) continue;
28
+ map.set(String(id), request);
29
+ }
30
+ return map;
31
+ }
32
+ }
@@ -6,21 +6,18 @@ import type { RequestResult } from '../api/request-result.ts';
6
6
  import type { RequestStore } from '../api/request-store.ts';
7
7
  import { extractEndpointDefinition } from '../api/spec-reader.ts';
8
8
  import { tag } from '../utils/logger.ts';
9
- import { RequestMap } from '../utils/request-map.ts';
10
9
  import { isDynamicSegment } from '../utils/url-matcher.ts';
10
+ import type { RequestHaul } from './fisherman/request-haul.ts';
11
11
 
12
- export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
12
+ export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string }) {
13
13
  let finished = false;
14
14
  let result: FishermanResult | null = null;
15
- const ledgerStart = requestStore.getMadeRequests().length;
16
15
 
17
- const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
18
- const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
19
- const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
16
+ const getResult = () => result ?? synthesizeResult(haul, false);
20
17
  const isFinished = () => finished;
21
18
  const finishFromText = (text?: string) => {
22
19
  finished = true;
23
- const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
20
+ const synthesized = synthesizeResult(haul, true);
24
21
  if (text && synthesized.success) synthesized.summary = text;
25
22
  result = synthesized;
26
23
  };
@@ -160,32 +157,12 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
160
157
  .describe('List of items that could not be created'),
161
158
  }),
162
159
  execute: async ({ summary, created, failed }) => {
163
- const writes = successfulWrites();
164
- if (writes.length === 0) {
165
- tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
166
- return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
167
- }
168
-
169
- const createdRequests = new RequestMap(writes);
170
-
171
- const verified: FishermanResult['created'] = [];
172
- for (const item of created) {
173
- if (item.id === undefined) {
174
- verified.push(item);
175
- continue;
176
- }
177
- const request = createdRequests.get(item.id);
178
- if (!request) {
179
- tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
180
- continue;
181
- }
182
- verified.push({ ...item, request: request.toEndpoint() });
183
- }
184
- if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
160
+ const { result: verified, error } = verifyFinish(haul, { summary, created, failed });
161
+ if (!verified) return { finished: false, error };
185
162
 
186
163
  tag('success').log(`Fisherman done: ${summary}`);
187
164
  finished = true;
188
- result = { success: true, summary, created: verified, failed: failed || [] };
165
+ result = verified;
189
166
  return { finished: true };
190
167
  },
191
168
  }),
@@ -207,8 +184,37 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
207
184
  return { tools, getResult, isFinished, finishFromText };
208
185
  }
209
186
 
210
- function synthesizeResult(made: RequestResult[], writes: RequestResult[], declaredDone: boolean): FishermanResult {
211
- const failures = made.filter((r) => r.status >= 400 || r.error);
187
+ export function verifyFinish(haul: RequestHaul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } {
188
+ const writes = haul.successfulWrites();
189
+ if (writes.length === 0) {
190
+ tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
191
+ return { result: null, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
192
+ }
193
+
194
+ const createdRequests = haul.byId();
195
+
196
+ const verified: FishermanResult['created'] = [];
197
+ for (const item of input.created) {
198
+ if (item.id === undefined) {
199
+ verified.push(item);
200
+ continue;
201
+ }
202
+ const request = createdRequests.get(String(item.id));
203
+ if (!request) {
204
+ tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
205
+ continue;
206
+ }
207
+ verified.push({ ...item, request: request.toEndpoint() });
208
+ }
209
+ if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
210
+
211
+ return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
212
+ }
213
+
214
+ function synthesizeResult(haul: RequestHaul, declaredDone: boolean): FishermanResult {
215
+ const made = haul.requests();
216
+ const writes = haul.successfulWrites();
217
+ const failures = haul.failed();
212
218
  let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
213
219
  const lastFailure = failures[failures.length - 1];
214
220
  if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
@@ -1,6 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import type { ApiClient } from '../api/api-client.ts';
3
- import type { RequestStore } from '../api/request-store.ts';
3
+ import { type RequestStore, isFailedRequest } from '../api/request-store.ts';
4
4
  import { listAllEndpoints } from '../api/spec-reader.ts';
5
5
  import { createDebug, tag } from '../utils/logger.ts';
6
6
 
@@ -8,6 +8,7 @@ const debugLog = createDebug('explorbot:fisherman');
8
8
  import { loop } from '../utils/loop.ts';
9
9
  import type { Agent } from './agent.ts';
10
10
  import { type FishermanResult, createFishermanTools } from './fisherman-tools.ts';
11
+ import { RequestHaul } from './fisherman/request-haul.ts';
11
12
  import type { Provider } from './provider.ts';
12
13
  import { dataProtectionRules } from './rules.ts';
13
14
 
@@ -79,11 +80,11 @@ export class Fisherman implements Agent {
79
80
  await this.refreshAuth();
80
81
  debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
81
82
 
82
- const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
83
+ const haul = new RequestHaul(this.requestStore);
84
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
83
85
  spec: this.spec,
84
86
  baseEndpoint: this.baseEndpoint,
85
87
  });
86
- const ledgerStart = this.requestStore.getMadeRequests().length;
87
88
 
88
89
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
89
90
  conversation.addUserText(this.buildTaskPrompt(instructions));
@@ -109,7 +110,7 @@ export class Fisherman implements Agent {
109
110
  return;
110
111
  }
111
112
 
112
- if (this.isStuckOnEndpoint(ledgerStart)) {
113
+ if (this.isStuckOnEndpoint(haul)) {
113
114
  tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
114
115
  stop();
115
116
  return;
@@ -228,12 +229,12 @@ export class Fisherman implements Agent {
228
229
  `;
229
230
  }
230
231
 
231
- private isStuckOnEndpoint(ledgerStart: number): boolean {
232
- const made = this.requestStore.getMadeRequests().slice(ledgerStart);
232
+ private isStuckOnEndpoint(haul: RequestHaul): boolean {
233
+ const made = haul.requests();
233
234
  if (made.length < REPEATED_FAILURE_LIMIT) return false;
234
235
  const recent = made.slice(-REPEATED_FAILURE_LIMIT);
235
236
  const first = recent[0];
236
- return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
237
+ return recent.every((r) => isFailedRequest(r) && r.method === first.method && r.path === first.path);
237
238
  }
238
239
 
239
240
  private buildTaskPrompt(instructions: string): string {
package/src/ai/pilot.ts CHANGED
@@ -31,6 +31,7 @@ const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
31
31
  const PILOT_REASONING_LIMIT = 500;
32
32
  const PILOT_MESSAGE_LIMIT = 2;
33
33
  const PILOT_MESSAGE_MAX_LENGTH = 160;
34
+ const PILOT_REQUEST_LIMIT = 5;
34
35
 
35
36
  export class Pilot implements Agent {
36
37
  emoji = '🧭';
@@ -826,17 +827,17 @@ export class Pilot implements Agent {
826
827
  lines.push(`h3: ${state.h3 || ''}`);
827
828
  lines.push(`h4: ${state.h4 || ''}`);
828
829
 
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})`;
830
+ const region = state.overlay;
831
+ if (region.isModal) {
832
+ let line = `overlay: ${region.name || region.type}`;
833
+ if (region.root) line += ` (root: ${region.root})`;
833
834
  lines.push(line);
834
- } else if (focusArea.present) {
835
- let line = `region: ${focusArea.name || 'unnamed'} (inline`;
836
- if (focusArea.root) line += `, root: ${focusArea.root}`;
835
+ } else if (region.isOpen) {
836
+ let line = `region: ${region.name || 'unnamed'} (inline`;
837
+ if (region.root) line += `, root: ${region.root}`;
837
838
  lines.push(`${line})`);
838
839
  } else {
839
- lines.push('modal: none');
840
+ lines.push('overlay: none');
840
841
  }
841
842
 
842
843
  const tabs = this.stateManager.otherTabs;
@@ -1071,9 +1072,13 @@ export class Pilot implements Agent {
1071
1072
 
1072
1073
  if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
1073
1074
 
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(', ')}`;
1075
+ const pageRequests = t.output?.pageDiff?.requests ?? [];
1076
+ const requests = pageRequests
1077
+ .filter((r: any) => r.status >= 400)
1078
+ .concat(pageRequests.filter((r: any) => r.status < 400))
1079
+ .slice(0, PILOT_REQUEST_LIMIT);
1080
+ if (requests.length > 0) {
1081
+ line += `\n requests: ${requests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
1077
1082
  }
1078
1083
 
1079
1084
  const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
@@ -1139,15 +1144,15 @@ export class Pilot implements Agent {
1139
1144
 
1140
1145
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1141
1146
  - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1142
- - "modal: none" but Tester targets a modalmodal closed; re-trigger.
1147
+ - "overlay: none" but Tester targets an overlayoverlay closed; re-trigger.
1143
1148
  - "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
1149
  - 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().
1150
+ - MultipleElementsFound → nothing was clicked. Tell Tester to reuse the same locator with step.opts({ elementIndex: N }) from the numbered elements list.
1146
1151
  - Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
1147
1152
  - Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
1148
1153
  - form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
1149
1154
  - 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.
1155
+ - 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().
1151
1156
  - Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
1152
1157
  - Multi-action explanation in one tool call → instruct Tester to split.
1153
1158
 
@@ -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
@@ -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`