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
@@ -6,6 +6,7 @@ import { htmlDiff, liveRegionMessages } from "./utils/html-diff.js";
6
6
  import { extractHeadings, extractLinks, extractTargetedHtml, htmlCombinedSnapshot, htmlMinimalUISnapshot, htmlTextSnapshot, minifyHtml } from "./utils/html.js";
7
7
  import { createDebug } from "./utils/logger.js";
8
8
  import { Overlay } from "./utils/overlay.js";
9
+ import { Region } from "./utils/region.js";
9
10
  import { slugify } from "./utils/strings.js";
10
11
  import { extractStatePath, matchesUrl } from "./utils/url-matcher.js";
11
12
  const debugLog = createDebug('explorbot:state');
@@ -38,7 +39,7 @@ export class ActionResult {
38
39
  notes = [];
39
40
  links = [];
40
41
  verifications;
41
- overlay = new Overlay();
42
+ overlay = new Region();
42
43
  _diffCache = null;
43
44
  constructor(data) {
44
45
  this.id = data.id;
@@ -198,7 +199,7 @@ export class ActionResult {
198
199
  if (record.region && this.overlay.name !== record.region)
199
200
  return false;
200
201
  if (record.root) {
201
- if (!this.overlay.present)
202
+ if (!this.overlay.isOpen)
202
203
  return false;
203
204
  if (this.overlay.root && this.overlay.root !== record.root)
204
205
  return false;
@@ -446,15 +447,12 @@ export class ActionResult {
446
447
  pageDiff.ariaChanges = diff.ariaChanged;
447
448
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
448
449
  }
449
- if (this.overlay.present && (!previousState.overlay.present || previousState.overlay.name !== this.overlay.name)) {
450
+ if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
450
451
  pageDiff.areaOfInterest = this.overlay.describe();
451
452
  }
452
453
  if (pageDiff.areaOfInterest && this.overlay.html && this.overlay.root) {
453
454
  const htmlConfig = ConfigParser.getInstance().getConfig().html;
454
- let subtree = await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined));
455
- if (subtree.length > HTML_PART_SUBTREE_BUDGET) {
456
- subtree = `${subtree.slice(0, HTML_PART_SUBTREE_BUDGET)}...<!-- truncated -->`;
457
- }
455
+ const subtree = trimSubtree(await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined)));
458
456
  pageDiff.htmlParts = [{ container: this.overlay.root, subtree, rawSize: subtree.length, added: [], removed: [] }];
459
457
  }
460
458
  else if (diff.isSameUrl() && diff.htmlParts.length > 0) {
@@ -478,7 +476,7 @@ export class ActionResult {
478
476
  parts.push(`h1_${this.h1}`);
479
477
  if (this.h2)
480
478
  parts.push(`h2_${this.h2}`);
481
- if (includeRegion && this.overlay.present && this.overlay.name)
479
+ if (includeRegion && this.overlay.isOpen && this.overlay.name)
482
480
  parts.push(`region_${this.overlay.name}`);
483
481
  let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
484
482
  if (stateString.length > 200) {
@@ -511,6 +509,7 @@ const CONSOLE_ERROR_LIMIT = 3;
511
509
  const HTML_PARTS_TOTAL_BUDGET = 8000;
512
510
  const HTML_PARTS_COUNT_LIMIT = 8;
513
511
  const HTML_PART_SUBTREE_BUDGET = 2000;
512
+ const SUBTREE_TAIL_BUDGET = 600;
514
513
  function collapseHtmlParts(parts) {
515
514
  const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
516
515
  const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
@@ -522,15 +521,14 @@ function collapseHtmlParts(parts) {
522
521
  subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
523
522
  }));
524
523
  }
525
- return parts.map((part) => {
526
- if (part.subtree.length <= HTML_PART_SUBTREE_BUDGET)
527
- return part;
528
- const head = part.subtree.slice(0, HTML_PART_SUBTREE_BUDGET);
529
- return {
530
- ...part,
531
- subtree: `${head}...<!-- truncated ${part.subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->`,
532
- };
533
- });
524
+ return parts.map((part) => ({ ...part, subtree: trimSubtree(part.subtree) }));
525
+ }
526
+ function trimSubtree(subtree) {
527
+ if (subtree.length <= HTML_PART_SUBTREE_BUDGET)
528
+ return subtree;
529
+ const head = subtree.slice(0, HTML_PART_SUBTREE_BUDGET - SUBTREE_TAIL_BUDGET);
530
+ const tail = subtree.slice(-SUBTREE_TAIL_BUDGET);
531
+ return `${head}...<!-- truncated ${subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->${tail}`;
534
532
  }
535
533
  export class Diff {
536
534
  current;
@@ -33,7 +33,7 @@ declare class Action {
33
33
  includeScreenshot?: boolean;
34
34
  codeBlock?: string;
35
35
  }): Promise<ActionResult>;
36
- detectRegionOfInterest(result: ActionResult): Promise<void>;
36
+ detectRegion(result: ActionResult): Promise<void>;
37
37
  captureMainDocumentStatus(): Promise<number | undefined>;
38
38
  captureResponses(): () => void;
39
39
  recordNetworkCall(request: any, status: number): void;
@@ -166,7 +166,7 @@ class Action {
166
166
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
167
167
  });
168
168
  if (!frame)
169
- await this.detectRegionOfInterest(result).catch((err) => debugLog('Region detection failed:', err.message));
169
+ await this.detectRegion(result).catch((err) => debugLog('Region detection failed:', err.message));
170
170
  this.stateManager.updateState(result, codeBlock);
171
171
  return result;
172
172
  }
@@ -179,36 +179,36 @@ class Action {
179
179
  return new ActionResult({ url, error: msg });
180
180
  }
181
181
  }
182
- async detectRegionOfInterest(result) {
182
+ async detectRegion(result) {
183
183
  const previousState = this.stateManager.getCurrentState();
184
184
  if (!previousState)
185
185
  return;
186
186
  const previous = ActionResult.fromState(previousState);
187
- const previousOverlay = previous.overlay;
187
+ const previousRegion = previous.overlay;
188
188
  const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
189
189
  const overlayPage = new OverlayPage(this.playwrightHelper.page);
190
- if (result.overlay.detected && previousOverlay.detected && previousOverlay.root && previousOverlay.type === result.overlay.type && previousOverlay.name === result.overlay.name) {
191
- result.overlay = previousOverlay;
190
+ if (result.overlay.isModal && previousRegion.isModal && previousRegion.root && previousRegion.type === result.overlay.type && previousRegion.name === result.overlay.name) {
191
+ result.overlay = previousRegion;
192
192
  return;
193
193
  }
194
194
  if (!previous.html)
195
195
  return;
196
196
  if (previous.html === result.html) {
197
- if (sameUrl && previousOverlay.present && previousOverlay.xpath && !result.overlay.detected)
198
- result.overlay = previousOverlay;
197
+ if (sameUrl && previousRegion.isOpen && previousRegion.xpath && !result.overlay.isModal)
198
+ result.overlay = previousRegion;
199
199
  return;
200
200
  }
201
201
  let carried = null;
202
- if (sameUrl && previousOverlay.present && previousOverlay.xpath) {
203
- if (await overlayPage.isStillOpen(previousOverlay)) {
204
- carried = previousOverlay;
202
+ if (sameUrl && previousRegion.isOpen && previousRegion.xpath) {
203
+ if (await overlayPage.isStillOpen(previousRegion)) {
204
+ carried = previousRegion;
205
205
  }
206
206
  else {
207
- debugLog(`Region closed: ${previousOverlay.name || previousOverlay.type}`);
208
- if (!result.overlay.detected) {
209
- const parent = previousOverlay.parent;
207
+ debugLog(`Region closed: ${previousRegion.name || previousRegion.type}`);
208
+ if (!result.overlay.isModal) {
209
+ const parent = previousRegion.parent;
210
210
  if (parent?.xpath) {
211
- const restored = new Overlay(parent);
211
+ const restored = Overlay.resolve({ overlay: parent });
212
212
  if (await overlayPage.isStillOpen(restored))
213
213
  result.overlay = restored;
214
214
  }
@@ -224,7 +224,7 @@ class Action {
224
224
  sameUrl,
225
225
  previousHtml: previous.html,
226
226
  });
227
- if (result.overlay.detected) {
227
+ if (result.overlay instanceof Overlay) {
228
228
  if (detected)
229
229
  result.overlay = result.overlay.withGeometry(detected);
230
230
  return;
@@ -137,7 +137,7 @@ export class Captain extends CaptainBase {
137
137
  const headingLines = formatHeadings(state);
138
138
  const headingsBlock = headingLines.join('\n');
139
139
  let pageSummary = '';
140
- const cachedResearch = Researcher.getCachedResearch(state);
140
+ const cachedResearch = Researcher.getCachedResearch(actionResult);
141
141
  if (cachedResearch) {
142
142
  pageSummary = `<page_summary>\n${this.explorBot.agentResearcher().extractBrief(cachedResearch)}\n</page_summary>`;
143
143
  }
@@ -0,0 +1,11 @@
1
+ import type { RequestResult } from '../../api/request-result.js';
2
+ import { type RequestStore } from '../../api/request-store.js';
3
+ export declare class RequestHaul {
4
+ store: RequestStore;
5
+ start: number;
6
+ constructor(store: RequestStore);
7
+ requests(): RequestResult[];
8
+ failed(): RequestResult[];
9
+ successfulWrites(): RequestResult[];
10
+ byId(): Map<string, RequestResult>;
11
+ }
@@ -0,0 +1,28 @@
1
+ import { isFailedRequest } from "../../api/request-store.js";
2
+ export class RequestHaul {
3
+ store;
4
+ start;
5
+ constructor(store) {
6
+ this.store = store;
7
+ this.start = store.getMadeRequests().length;
8
+ }
9
+ requests() {
10
+ return this.store.getMadeRequests().slice(this.start);
11
+ }
12
+ failed() {
13
+ return this.requests().filter(isFailedRequest);
14
+ }
15
+ successfulWrites() {
16
+ return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
17
+ }
18
+ byId() {
19
+ const map = new Map();
20
+ for (const request of this.successfulWrites()) {
21
+ const { id } = request.extractIdAndTitle();
22
+ if (id === undefined)
23
+ continue;
24
+ map.set(String(id), request);
25
+ }
26
+ return map;
27
+ }
28
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ApiClient } from '../api/api-client.js';
2
2
  import type { RequestStore } from '../api/request-store.js';
3
- export declare function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: {
3
+ import type { RequestHaul } from './fisherman/request-haul.js';
4
+ export declare function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: {
4
5
  spec?: any;
5
6
  baseEndpoint?: string;
6
7
  }): {
@@ -120,6 +121,14 @@ export declare function createFishermanTools(apiClient: ApiClient, requestStore:
120
121
  isFinished: () => boolean;
121
122
  finishFromText: (text?: string) => void;
122
123
  };
124
+ export declare function verifyFinish(haul: RequestHaul, input: {
125
+ summary: string;
126
+ created: FishermanResult['created'];
127
+ failed?: FishermanResult['failed'];
128
+ }): {
129
+ result: FishermanResult | null;
130
+ error?: string;
131
+ };
123
132
  export interface FishermanResult {
124
133
  success: boolean;
125
134
  summary: string;
@@ -3,19 +3,15 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { extractEndpointDefinition } from "../api/spec-reader.js";
5
5
  import { tag } from "../utils/logger.js";
6
- import { RequestMap } from "../utils/request-map.js";
7
6
  import { isDynamicSegment } from "../utils/url-matcher.js";
8
- export function createFishermanTools(apiClient, requestStore, opts) {
7
+ export function createFishermanTools(apiClient, requestStore, haul, opts) {
9
8
  let finished = false;
10
9
  let result = null;
11
- const ledgerStart = requestStore.getMadeRequests().length;
12
- const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart);
13
- const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
14
- const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
10
+ const getResult = () => result ?? synthesizeResult(haul, false);
15
11
  const isFinished = () => finished;
16
12
  const finishFromText = (text) => {
17
13
  finished = true;
18
- const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
14
+ const synthesized = synthesizeResult(haul, true);
19
15
  if (text && synthesized.success)
20
16
  synthesized.summary = text;
21
17
  result = synthesized;
@@ -142,30 +138,12 @@ export function createFishermanTools(apiClient, requestStore, opts) {
142
138
  .describe('List of items that could not be created'),
143
139
  }),
144
140
  execute: async ({ summary, created, failed }) => {
145
- const writes = successfulWrites();
146
- if (writes.length === 0) {
147
- tag('warning').log('Fisherman: finish rejected no successful write request in this run');
148
- 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.' };
149
- }
150
- const createdRequests = new RequestMap(writes);
151
- const verified = [];
152
- for (const item of created) {
153
- if (item.id === undefined) {
154
- verified.push(item);
155
- continue;
156
- }
157
- const request = createdRequests.get(item.id);
158
- if (!request) {
159
- tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
160
- continue;
161
- }
162
- verified.push({ ...item, request: request.toEndpoint() });
163
- }
164
- if (verified.length === 0)
165
- verified.push(...writes.map(toCreatedItem));
141
+ const { result: verified, error } = verifyFinish(haul, { summary, created, failed });
142
+ if (!verified)
143
+ return { finished: false, error };
166
144
  tag('success').log(`Fisherman done: ${summary}`);
167
145
  finished = true;
168
- result = { success: true, summary, created: verified, failed: failed || [] };
146
+ result = verified;
169
147
  return { finished: true };
170
148
  },
171
149
  }),
@@ -184,8 +162,34 @@ export function createFishermanTools(apiClient, requestStore, opts) {
184
162
  };
185
163
  return { tools, getResult, isFinished, finishFromText };
186
164
  }
187
- function synthesizeResult(made, writes, declaredDone) {
188
- const failures = made.filter((r) => r.status >= 400 || r.error);
165
+ export function verifyFinish(haul, input) {
166
+ const writes = haul.successfulWrites();
167
+ if (writes.length === 0) {
168
+ tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
169
+ 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.' };
170
+ }
171
+ const createdRequests = haul.byId();
172
+ const verified = [];
173
+ for (const item of input.created) {
174
+ if (item.id === undefined) {
175
+ verified.push(item);
176
+ continue;
177
+ }
178
+ const request = createdRequests.get(String(item.id));
179
+ if (!request) {
180
+ tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
181
+ continue;
182
+ }
183
+ verified.push({ ...item, request: request.toEndpoint() });
184
+ }
185
+ if (verified.length === 0)
186
+ verified.push(...writes.map(toCreatedItem));
187
+ return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
188
+ }
189
+ function synthesizeResult(haul, declaredDone) {
190
+ const made = haul.requests();
191
+ const writes = haul.successfulWrites();
192
+ const failures = haul.failed();
189
193
  let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
190
194
  const lastFailure = failures[failures.length - 1];
191
195
  if (lastFailure)
@@ -1,7 +1,8 @@
1
1
  import type { ApiClient } from '../api/api-client.js';
2
- import type { RequestStore } from '../api/request-store.js';
2
+ import { type RequestStore } from '../api/request-store.js';
3
3
  import type { Agent } from './agent.js';
4
4
  import { type FishermanResult } from './fisherman-tools.js';
5
+ import { RequestHaul } from './fisherman/request-haul.js';
5
6
  import type { Provider } from './provider.js';
6
7
  export declare class Fisherman implements Agent {
7
8
  emoji: string;
@@ -26,6 +27,6 @@ export declare class Fisherman implements Agent {
26
27
  refreshAuth(): Promise<void>;
27
28
  buildEndpointList(scopeUrl?: string): string;
28
29
  buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string;
29
- isStuckOnEndpoint(ledgerStart: number): boolean;
30
+ isStuckOnEndpoint(haul: RequestHaul): boolean;
30
31
  buildTaskPrompt(instructions: string): string;
31
32
  }
@@ -1,9 +1,11 @@
1
1
  import dedent from 'dedent';
2
+ import { isFailedRequest } from "../api/request-store.js";
2
3
  import { listAllEndpoints } from "../api/spec-reader.js";
3
4
  import { createDebug, tag } from "../utils/logger.js";
4
5
  const debugLog = createDebug('explorbot:fisherman');
5
6
  import { loop } from "../utils/loop.js";
6
7
  import { createFishermanTools } from "./fisherman-tools.js";
8
+ import { RequestHaul } from "./fisherman/request-haul.js";
7
9
  import { dataProtectionRules } from "./rules.js";
8
10
  const MAX_ITERATIONS = 15;
9
11
  const MAX_TOOL_ROUNDTRIPS = 5;
@@ -61,11 +63,11 @@ export class Fisherman {
61
63
  }
62
64
  await this.refreshAuth();
63
65
  debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
64
- const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, {
66
+ const haul = new RequestHaul(this.requestStore);
67
+ const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
65
68
  spec: this.spec,
66
69
  baseEndpoint: this.baseEndpoint,
67
70
  });
68
- const ledgerStart = this.requestStore.getMadeRequests().length;
69
71
  const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
70
72
  conversation.addUserText(this.buildTaskPrompt(instructions));
71
73
  await loop(async ({ stop, iteration }) => {
@@ -85,7 +87,7 @@ export class Fisherman {
85
87
  stop();
86
88
  return;
87
89
  }
88
- if (this.isStuckOnEndpoint(ledgerStart)) {
90
+ if (this.isStuckOnEndpoint(haul)) {
89
91
  tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
90
92
  stop();
91
93
  return;
@@ -190,13 +192,13 @@ export class Fisherman {
190
192
  ${dataProtectionRules}
191
193
  `;
192
194
  }
193
- isStuckOnEndpoint(ledgerStart) {
194
- const made = this.requestStore.getMadeRequests().slice(ledgerStart);
195
+ isStuckOnEndpoint(haul) {
196
+ const made = haul.requests();
195
197
  if (made.length < REPEATED_FAILURE_LIMIT)
196
198
  return false;
197
199
  const recent = made.slice(-REPEATED_FAILURE_LIMIT);
198
200
  const first = recent[0];
199
- return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path);
201
+ return recent.every((r) => isFailedRequest(r) && r.method === first.method && r.path === first.path);
200
202
  }
201
203
  buildTaskPrompt(instructions) {
202
204
  return dedent `
@@ -522,7 +522,7 @@ class Navigator {
522
522
  return null;
523
523
  }
524
524
  const currentActionResult = actionResult || ActionResult.fromState(state);
525
- const research = Researcher.getCachedResearch(state) || '';
525
+ const research = Researcher.getCachedResearch(currentActionResult) || '';
526
526
  const combinedHtml = await currentActionResult.combinedHtml();
527
527
  const history = stateManager.getStateHistory();
528
528
  const visitCounts = new Map();
@@ -19,6 +19,7 @@ const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
19
19
  const PILOT_REASONING_LIMIT = 500;
20
20
  const PILOT_MESSAGE_LIMIT = 2;
21
21
  const PILOT_MESSAGE_MAX_LENGTH = 160;
22
+ const PILOT_REQUEST_LIMIT = 5;
22
23
  export class Pilot {
23
24
  emoji = '🧭';
24
25
  provider;
@@ -741,21 +742,21 @@ export class Pilot {
741
742
  lines.push(`h2: ${state.h2 || ''}`);
742
743
  lines.push(`h3: ${state.h3 || ''}`);
743
744
  lines.push(`h4: ${state.h4 || ''}`);
744
- const focusArea = state.overlay;
745
- if (focusArea.detected) {
746
- let line = `modal: ${focusArea.name || focusArea.type}`;
747
- if (focusArea.root)
748
- line += ` (root: ${focusArea.root})`;
745
+ const region = state.overlay;
746
+ if (region.isModal) {
747
+ let line = `overlay: ${region.name || region.type}`;
748
+ if (region.root)
749
+ line += ` (root: ${region.root})`;
749
750
  lines.push(line);
750
751
  }
751
- else if (focusArea.present) {
752
- let line = `region: ${focusArea.name || 'unnamed'} (inline`;
753
- if (focusArea.root)
754
- line += `, root: ${focusArea.root}`;
752
+ else if (region.isOpen) {
753
+ let line = `region: ${region.name || 'unnamed'} (inline`;
754
+ if (region.root)
755
+ line += `, root: ${region.root}`;
755
756
  lines.push(`${line})`);
756
757
  }
757
758
  else {
758
- lines.push('modal: none');
759
+ lines.push('overlay: none');
759
760
  }
760
761
  const tabs = this.stateManager.otherTabs;
761
762
  if (tabs.length > 0) {
@@ -971,9 +972,13 @@ export class Pilot {
971
972
  line += `\n ${ariaDiff}`;
972
973
  if (t.output?.pageDiff?.urlChanged)
973
974
  line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
974
- const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r) => r.status >= 400);
975
- if (failedRequests.length > 0) {
976
- line += `\n requests: ${failedRequests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
975
+ const pageRequests = t.output?.pageDiff?.requests ?? [];
976
+ const requests = pageRequests
977
+ .filter((r) => r.status >= 400)
978
+ .concat(pageRequests.filter((r) => r.status < 400))
979
+ .slice(0, PILOT_REQUEST_LIMIT);
980
+ if (requests.length > 0) {
981
+ line += `\n requests: ${requests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
977
982
  }
978
983
  const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
979
984
  if (messages.length > 0) {
@@ -1034,15 +1039,15 @@ export class Pilot {
1034
1039
 
1035
1040
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1036
1041
  - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1037
- - "modal: none" but Tester targets a modalmodal closed; re-trigger.
1042
+ - "overlay: none" but Tester targets an overlayoverlay closed; re-trigger.
1038
1043
  - "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.
1039
1044
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
1040
- - MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
1045
+ - MultipleElementsFound → nothing was clicked. Tell Tester to reuse the same locator with step.opts({ elementIndex: N }) from the numbered elements list.
1041
1046
  - Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
1042
1047
  - Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
1043
1048
  - form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
1044
1049
  - ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
1045
- - Empty dropdown/list when items expected → missing data; call precondition() to create it.
1050
+ - 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().
1046
1051
  - Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
1047
1052
  - Multi-action explanation in one tool call → instruct Tester to split.
1048
1053
 
@@ -96,11 +96,15 @@ export class Planner extends PlannerBase {
96
96
  Tests must be relevant to the page
97
97
  Tests must be achievable from UI
98
98
  Tests must be verifiable from UI
99
- NEVER split one workflow into multiple tests. Each test must be a complete end-to-end flow.
99
+ One test verifies ONE business operation: the steps that reach it, the action itself, and its verification.
100
+ Steps that only reach the action — opening a form, expanding a panel, creating or locating the item to act on — belong to that test. A second operation with its own verification does not.
100
101
  Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
101
102
  Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
102
103
  Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
103
- If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}${focusExistingDataDirective}
104
+ Bad: "Create a record, rename it, delete it" three verified operations, so THREE tests, not one.
105
+ Good: "Rename existing record and verify the new title" — ONE test; creating is skipped, we assume record already exists, only the rename is verified.
106
+ You may rely on another test having run first in case we deal with empty state and no relevant data was created yet and we expect another our test creates it
107
+ When the page reports a record is missing or unavailable, it is not a testable surface — plan list-level or recovery behavior instead of operations on that record.${featureDirective}${focusExistingDataDirective}
104
108
  </task>
105
109
 
106
110
  ${customPrompt || ''}
@@ -166,9 +170,6 @@ export class Planner extends PlannerBase {
166
170
  if (!aiResult?.object?.scenarios) {
167
171
  throw new Error('No tasks were created successfully');
168
172
  }
169
- if (aiResult.object.scenarios.length === 0 && !this.currentPlan) {
170
- throw new Error('No tasks were created successfully');
171
- }
172
173
  const defaultStartUrl = this.getDefaultStartUrl(state);
173
174
  const fromPlanning = aiResult.object.scenarios.map((s) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
174
175
  return { tests: fromPlanning, planName: aiResult.object.planName };
@@ -295,6 +296,7 @@ export class Planner extends PlannerBase {
295
296
  <task>
296
297
  Based on the page research, create ${this.MIN_TASKS}-${this.MAX_TASKS} exploratory testing scenarios.
297
298
  For each scenario provide specific steps and expected outcomes.
299
+ Exception: if the page reports the requested resource is missing, shows a failure state, or holds no content and no controls, return an empty scenarios list. Never invent tests for a page with nothing to exercise.
298
300
  </task>
299
301
 
300
302
  <rules>
@@ -308,13 +310,13 @@ export class Planner extends PlannerBase {
308
310
  Focus on error or success messages as outcome.
309
311
  Focus on URL page change or data persistency after page reload.
310
312
  If there are subpages (pages with same URL path) plan testing of those subpages as well
311
- If you plan to test CRUD operations, plan them in correct order: create, read, update.
313
+ Plan CRUD operations in order: create, read, update, delete.
312
314
  Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
313
315
  When validation placement or wording was not observed, require feedback associated with the invalid input without inventing a specific location or message.
314
316
  If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
315
317
  If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
316
318
  Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
317
- If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible or API preconditions can create it.
319
+ If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
318
320
  If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
319
321
  Do not assume hidden data exists just because a control is present.
320
322
  For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
@@ -10,6 +10,7 @@ import { configuredModels, modelName as getModelName } from '../config.js';
10
10
  import { executionController } from "../execution-controller.js";
11
11
  import { Observability } from "../observability.js";
12
12
  import { Stats } from "../stats.js";
13
+ import { getCliName } from '../utils/cli-name.js';
13
14
  import { createDebug, tag } from '../utils/logger.js';
14
15
  import { withRetry } from '../utils/retry.js';
15
16
  import { RulesLoader } from "../utils/rules-loader.js";
@@ -43,14 +44,7 @@ export async function flushTelemetry() {
43
44
  }
44
45
  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'];
45
46
  function extractCachedTokens(usage) {
46
- if (!usage)
47
- return 0;
48
- const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
49
- if (typeof direct === 'number')
50
- return direct;
51
- const raw = usage.raw;
52
- const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
53
- return typeof fromRaw === 'number' ? fromRaw : 0;
47
+ return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
54
48
  }
55
49
  function abortAfterIdle(ms, cancel, controller) {
56
50
  return new Promise((_, reject) => {
@@ -99,7 +93,7 @@ export class Provider {
99
93
  modelCallWaiters = [];
100
94
  constructor(config) {
101
95
  if (!config?.model) {
102
- throw new AiError('AI model is not configured. Set ai.model in your config file.');
96
+ throw new AiError(`AI model is not configured. Set ai.model in your config file, or pick one with ${getCliName()} recommended-models`);
103
97
  }
104
98
  this.config = config;
105
99
  this.initLangfuse();
@@ -226,8 +220,8 @@ export class Provider {
226
220
  if (!usage)
227
221
  return;
228
222
  Stats.recordTokens(agentName, modelName, {
229
- input: usage.inputTokens ?? usage.promptTokens ?? 0,
230
- output: usage.outputTokens ?? usage.completionTokens ?? 0,
223
+ input: usage.inputTokens ?? 0,
224
+ output: usage.outputTokens ?? 0,
231
225
  total: usage.totalTokens ?? 0,
232
226
  cached: extractCachedTokens(usage),
233
227
  });
@@ -387,7 +381,7 @@ export class Provider {
387
381
  let invalidRequestFeedbackAdded = false;
388
382
  const executedStepMessages = [];
389
383
  try {
390
- const response = await this.withModelRequestSlot(() => withRetry(async () => {
384
+ let response = await this.withModelRequestSlot(() => withRetry(async () => {
391
385
  const stepMessages = [];
392
386
  const onStepEnd = (step) => {
393
387
  stepMessages.push(...(step.response?.messages || []));
@@ -413,7 +407,7 @@ export class Provider {
413
407
  return result;
414
408
  }, this.getRetryOptions(options)));
415
409
  clearActivity();
416
- withExecutedSteps(response, executedStepMessages);
410
+ response = withExecutedSteps(response, executedStepMessages);
417
411
  // Log tool usage summary
418
412
  if (response.toolCalls && response.toolCalls.length > 0) {
419
413
  responseLog(response.toolCalls);
@@ -585,7 +579,7 @@ export class Provider {
585
579
  }
586
580
  async processImage(prompt, image) {
587
581
  if (!this.config.visionModel) {
588
- throw new Error('Vision model not configured. Please set ai.visionModel in your config.');
582
+ throw new Error(`Vision model not configured. Set ai.visionModel in your config; ${getCliName()} recommended-models lists the providers that serve one`);
589
583
  }
590
584
  setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
591
585
  const messages = [
@@ -8,6 +8,7 @@ export declare function visuallyAnnotateContainers(page: Page, containers: Array
8
8
  css: string;
9
9
  label: string;
10
10
  }>): Promise<number>;
11
+ export declare function removeVisualAnnotations(page: Page): Promise<void>;
11
12
  export declare function WithCoordinates<T extends Constructor>(Base: T): {
12
13
  new (...args: any[]): {
13
14
  explorer: Explorer;
@@ -20,6 +21,7 @@ export declare function WithCoordinates<T extends Constructor>(Base: T): {
20
21
  label: string;
21
22
  }>;
22
23
  }): Promise<number>;
24
+ removeVisualAnnotations(): Promise<void>;
23
25
  _analyzeScreenshotForVisualProps(): Promise<VisualAnalysisResult>;
24
26
  mergeVisualData(result: ResearchResult, visualData: Map<string, {
25
27
  coordinates: string | null;