explorbot 0.3.5 → 0.4.1

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 (155) hide show
  1. package/bin/explorbot-cli.ts +23 -16
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler.ts +7 -1
  4. package/boat/api-tester/src/apibot.ts +10 -4
  5. package/boat/api-tester/src/cli.ts +12 -2
  6. package/boat/api-tester/src/config.ts +28 -8
  7. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  8. package/boat/doc-collector/src/cli.ts +6 -1
  9. package/boat/doc-collector/src/docbot.ts +4 -2
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +23 -8
  12. package/boat/prima/src/envelope.ts +35 -9
  13. package/boat/prima/src/prima.ts +23 -10
  14. package/dist/bin/explorbot-cli.js +24 -16
  15. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  16. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  17. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  18. package/dist/boat/api-tester/src/apibot.js +7 -3
  19. package/dist/boat/api-tester/src/cli.js +12 -2
  20. package/dist/boat/api-tester/src/config.js +31 -8
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  22. package/dist/boat/doc-collector/src/cli.js +6 -1
  23. package/dist/boat/doc-collector/src/docbot.js +4 -2
  24. package/dist/boat/prima/bin/prima-cli.js +2 -0
  25. package/dist/boat/prima/src/cli.js +22 -8
  26. package/dist/boat/prima/src/envelope.js +24 -6
  27. package/dist/boat/prima/src/prima.js +23 -11
  28. package/dist/package.json +2 -2
  29. package/dist/rules/planner/styles/normal.md +1 -1
  30. package/dist/src/action-result.d.ts +9 -1
  31. package/dist/src/action-result.js +57 -18
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +87 -12
  34. package/dist/src/ai/captain.js +1 -1
  35. package/dist/src/ai/driller.d.ts +0 -1
  36. package/dist/src/ai/driller.js +8 -20
  37. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  38. package/dist/src/ai/fisherman-tools.js +52 -6
  39. package/dist/src/ai/fisherman.d.ts +4 -2
  40. package/dist/src/ai/fisherman.js +48 -27
  41. package/dist/src/ai/historian/codeceptjs.js +1 -1
  42. package/dist/src/ai/historian/playwright.js +1 -1
  43. package/dist/src/ai/navigator.js +1 -1
  44. package/dist/src/ai/pilot.d.ts +1 -0
  45. package/dist/src/ai/pilot.js +13 -1
  46. package/dist/src/ai/planner.js +9 -7
  47. package/dist/src/ai/provider.js +20 -3
  48. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  49. package/dist/src/ai/researcher.js +5 -3
  50. package/dist/src/ai/rules.js +3 -3
  51. package/dist/src/ai/tester.d.ts +3 -0
  52. package/dist/src/ai/tester.js +40 -3
  53. package/dist/src/ai/tools.d.ts +1 -0
  54. package/dist/src/ai/tools.js +13 -6
  55. package/dist/src/api/request-result.d.ts +2 -0
  56. package/dist/src/api/request-result.js +8 -2
  57. package/dist/src/api/request-store.d.ts +3 -2
  58. package/dist/src/api/request-store.js +66 -14
  59. package/dist/src/api/spec-reader.js +1 -1
  60. package/dist/src/commands/config-command.js +1 -1
  61. package/dist/src/commands/drill-command.js +1 -1
  62. package/dist/src/commands/explore-command.d.ts +6 -0
  63. package/dist/src/commands/explore-command.js +39 -3
  64. package/dist/src/commands/freesail-command.js +10 -1
  65. package/dist/src/commands/options/base-option.d.ts +8 -0
  66. package/dist/src/commands/options/base-option.js +12 -0
  67. package/dist/src/commands/options/index.d.ts +5 -0
  68. package/dist/src/commands/options/index.js +5 -0
  69. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  70. package/dist/src/commands/options/knowledge-option.js +12 -0
  71. package/dist/src/commands/options/ws-option.d.ts +7 -0
  72. package/dist/src/commands/options/ws-option.js +21 -0
  73. package/dist/src/commands/plans-command.js +6 -6
  74. package/dist/src/config.d.ts +1 -0
  75. package/dist/src/config.js +12 -0
  76. package/dist/src/experience-tracker.js +5 -0
  77. package/dist/src/explorbot.d.ts +0 -1
  78. package/dist/src/explorbot.js +24 -37
  79. package/dist/src/knowledge-tracker.d.ts +20 -7
  80. package/dist/src/knowledge-tracker.js +69 -31
  81. package/dist/src/remote.d.ts +0 -3
  82. package/dist/src/remote.js +0 -18
  83. package/dist/src/state-manager.d.ts +5 -1
  84. package/dist/src/state-manager.js +10 -7
  85. package/dist/src/test-plan.d.ts +3 -0
  86. package/dist/src/test-plan.js +27 -0
  87. package/dist/src/utils/aria.d.ts +1 -1
  88. package/dist/src/utils/aria.js +6 -42
  89. package/dist/src/utils/html-diff.d.ts +4 -0
  90. package/dist/src/utils/html-diff.js +62 -7
  91. package/dist/src/utils/html.d.ts +5 -15
  92. package/dist/src/utils/html.js +14 -85
  93. package/dist/src/utils/overlay.d.ts +56 -11
  94. package/dist/src/utils/overlay.js +191 -21
  95. package/dist/src/utils/request-map.d.ts +7 -0
  96. package/dist/src/utils/request-map.js +16 -0
  97. package/dist/src/utils/url-matcher.js +4 -2
  98. package/docs/api-testing/basics.md +15 -0
  99. package/docs/api-testing/planning.md +10 -1
  100. package/docs/reference/commands.md +32 -5
  101. package/docs/reference/websocket.md +1 -0
  102. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  103. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  104. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  105. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  106. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  107. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  108. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  109. package/docs/workflow/agentic-usage.md +12 -2
  110. package/docs/workflow/ci.md +1 -0
  111. package/docs/workflow/knowledge.md +46 -2
  112. package/package.json +2 -2
  113. package/rules/planner/styles/normal.md +1 -1
  114. package/src/action-result.ts +61 -22
  115. package/src/action.ts +87 -14
  116. package/src/ai/captain.ts +1 -1
  117. package/src/ai/driller.ts +7 -39
  118. package/src/ai/fisherman-tools.ts +56 -7
  119. package/src/ai/fisherman.ts +48 -28
  120. package/src/ai/historian/codeceptjs.ts +1 -1
  121. package/src/ai/historian/playwright.ts +1 -1
  122. package/src/ai/navigator.ts +1 -1
  123. package/src/ai/pilot.ts +9 -1
  124. package/src/ai/planner.ts +9 -8
  125. package/src/ai/provider.ts +21 -3
  126. package/src/ai/researcher/deep-analysis.ts +1 -2
  127. package/src/ai/researcher.ts +4 -3
  128. package/src/ai/rules.ts +3 -3
  129. package/src/ai/tester.ts +40 -3
  130. package/src/ai/tools.ts +17 -9
  131. package/src/api/request-result.ts +10 -2
  132. package/src/api/request-store.ts +60 -13
  133. package/src/api/spec-reader.ts +1 -1
  134. package/src/commands/config-command.ts +1 -1
  135. package/src/commands/drill-command.ts +1 -1
  136. package/src/commands/explore-command.ts +37 -3
  137. package/src/commands/freesail-command.ts +7 -1
  138. package/src/commands/options/base-option.ts +18 -0
  139. package/src/commands/options/index.ts +7 -0
  140. package/src/commands/options/knowledge-option.ts +14 -0
  141. package/src/commands/options/ws-option.ts +24 -0
  142. package/src/commands/plans-command.ts +6 -6
  143. package/src/config.ts +12 -0
  144. package/src/experience-tracker.ts +5 -1
  145. package/src/explorbot.ts +21 -37
  146. package/src/knowledge-tracker.ts +94 -36
  147. package/src/remote.ts +0 -20
  148. package/src/state-manager.ts +13 -7
  149. package/src/test-plan.ts +29 -0
  150. package/src/utils/aria.ts +7 -44
  151. package/src/utils/html-diff.ts +62 -7
  152. package/src/utils/html.ts +14 -91
  153. package/src/utils/overlay.ts +226 -23
  154. package/src/utils/request-map.ts +19 -0
  155. package/src/utils/url-matcher.ts +3 -2
@@ -45,7 +45,48 @@ While exploring, use the `/learn` command.
45
45
 
46
46
  ### API Testing
47
47
 
48
- [API testing](../api-testing/basics.md) shares the same `knowledge/` directory. `npx explorbot api know <endpoint> "<description>"` adds endpoint-scoped notes, stored with an `endpoint:` frontmatter field instead of `url:`.
48
+ [API testing](../api-testing/basics.md) shares the same `knowledge/` directory. `npx explorbot api know <endpoint> "<description>"` adds endpoint-scoped notes, stored with an `endpoint:` frontmatter field instead of `url:`. Chief reads them when planning an endpoint and Curler reads them when running its tests, so auth headers and payload rules reach both.
49
+
50
+ ## Per-Session Knowledge
51
+
52
+ `--knowledge` passes facts to a single run. Nothing is written to `knowledge/`, so credentials and one-off test data stay out of the repository.
53
+
54
+ ```bash
55
+ npx explorbot explore /pay --knowledge 'My credit card is 4111 1111 1111 1111'
56
+ ```
57
+
58
+ Plain text applies to every page. Add frontmatter to scope it, with the same URL patterns knowledge files use:
59
+
60
+ ```bash
61
+ npx explorbot explore / --knowledge '---
62
+ url: /pay
63
+ ---
64
+ Use the sandbox card 4111 1111 1111 1111 with any future expiry'
65
+ ```
66
+
67
+ Repeat the flag for several facts:
68
+
69
+ ```bash
70
+ npx explorbot explore / \
71
+ --knowledge '---
72
+ url: /login
73
+ ---
74
+ Log in as admin@example.com / secret123' \
75
+ --knowledge 'Dismiss the cookie banner before anything else'
76
+ ```
77
+
78
+ Everything a knowledge file supports works here: `${env.VAR}` interpolation, and page automation fields such as `wait` and `waitForElement`.
79
+
80
+ The flag works on every command of `explorbot`, `explorbot api`, `explorbot docs` and `prima`, and can go anywhere on the line. Scope API knowledge with `endpoint:` instead of `url:`:
81
+
82
+ ```bash
83
+ npx explorbot api explore /orders --knowledge '---
84
+ endpoint: /orders/*
85
+ ---
86
+ Send X-Api-Key: ${env.API_KEY} on every request'
87
+ ```
88
+
89
+ For runs driven from the environment — config-free, or on the global configuration — `EXPLORBOT_KNOWLEDGE` does the same job for the length of one run. See [Agentic usage](./agentic-usage.md).
49
90
 
50
91
  ## URL Patterns
51
92
 
@@ -81,10 +122,13 @@ Notes:
81
122
 
82
123
  | Field | Purpose |
83
124
  |-------|---------|
84
- | `url` | URL pattern to match (optional, defaults to `*`) |
125
+ | `url` | Page URL pattern to match |
126
+ | `endpoint` | API endpoint pattern to match |
85
127
  | `title` | Human-readable title (optional) |
86
128
  | Custom fields | Any additional metadata for agents |
87
129
 
130
+ `url` scopes a file to browser pages and `endpoint` scopes it to API endpoints. A file with neither applies to both.
131
+
88
132
  ## Variables
89
133
 
90
134
  Knowledge files support variable interpolation with `${namespace.key}` syntax. Explorbot resolves variables when it loads the knowledge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -77,7 +77,7 @@
77
77
  "dependencies": {
78
78
  "@ai-sdk/anthropic": "^4.0",
79
79
  "@ai-sdk/google": "^4.0.18",
80
- "@ai-sdk/groq": "^4.0",
80
+ "@ai-sdk/groq": "^4.0.34",
81
81
  "@ai-sdk/mistral": "^4.0.13",
82
82
  "@ai-sdk/openai": "^4.0",
83
83
  "@ai-sdk/otel": "^1.0.2",
@@ -1,7 +1,7 @@
1
1
  Study the page and figure out its business purpose. What is this page FOR? What would a user come here to do?
2
2
 
3
3
  Based on the page type, propose tests for COMPLETE user workflows:
4
- - If this is a data page (lists, tables): test CRUD operations end-to-end (create item → verify in list, edit item verify changes saved, delete item verify removed)
4
+ - If this is a data page (lists, tables): test create, edit and delete as separate tests, each preparing the item it acts on
5
5
  - If the page has inputs to fill in: test the full commit flow, not just that the controls render
6
6
  - If this has filters and search: test filtering AND verify results change, not just "filter tab clicked"
7
7
  - If this has modals/dropdowns: test the ACTION inside them, not just opening/closing them
@@ -35,7 +35,6 @@ interface ActionResultData extends WebPageState {
35
35
  focusedElement?: FocusedElement | null;
36
36
  iframeURL?: string;
37
37
  links?: Link[];
38
- overlayHtml?: string;
39
38
  }
40
39
 
41
40
  export interface PageDiff {
@@ -49,6 +48,7 @@ export interface PageDiff {
49
48
  consoleErrors?: string[];
50
49
  htmlParts?: HtmlDiffPart[];
51
50
  iframes?: string;
51
+ areaOfInterest?: string;
52
52
  }
53
53
 
54
54
  export interface ToolResultMetadata {
@@ -89,6 +89,7 @@ export class ActionResult implements ActionResultData {
89
89
  public links: Link[] = [];
90
90
  public verifications?: Record<string, boolean>;
91
91
  public overlay: Overlay = new Overlay();
92
+ private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
92
93
 
93
94
  constructor(data: ActionResultData) {
94
95
  this.id = data.id;
@@ -168,6 +169,7 @@ export class ActionResult implements ActionResultData {
168
169
  set html(value: string) {
169
170
  this._html = value;
170
171
  this.snapshotCache.clear();
172
+ this._diffCache = null;
171
173
  }
172
174
 
173
175
  get screenshot(): Buffer | undefined {
@@ -260,6 +262,11 @@ export class ActionResult implements ActionResultData {
260
262
 
261
263
  isRelevantExperienceRecord(record: WebPageState, options?: { includeDescendantExperience?: boolean }): boolean {
262
264
  if (!record.url || !this.url) return false;
265
+ if (record.region && this.overlay.name !== record.region) return false;
266
+ if (record.root) {
267
+ if (!this.overlay.present) return false;
268
+ if (this.overlay.root && this.overlay.root !== record.root) return false;
269
+ }
263
270
  if (this.isMatchedBy(record)) return true;
264
271
  if (!options?.includeDescendantExperience) return false;
265
272
  const cur = extractStatePath(this.url);
@@ -476,29 +483,18 @@ export class ActionResult implements ActionResultData {
476
483
  }
477
484
 
478
485
  getStateHash(): string {
479
- const parts: string[] = [];
480
-
481
- parts.push(this.relativeUrl || this.url || '/');
482
-
483
- this.extractHeadings(this.html);
484
-
485
- if (this.h1) parts.push(`h1_${this.h1}`);
486
- if (this.h2) parts.push(`h2_${this.h2}`);
487
-
488
- let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
489
-
490
- if (stateString.length > 200) {
491
- stateString = stateString.substring(0, 200);
492
- if (stateString.endsWith('_')) {
493
- stateString = stateString.slice(0, -1);
494
- }
495
- }
486
+ return this.computeStateHash(true);
487
+ }
496
488
 
497
- return stateString;
489
+ get baseHash(): string {
490
+ return this.computeStateHash(false);
498
491
  }
499
492
 
500
493
  async diff(previousState: ActionResult | null): Promise<Diff> {
501
- return Diff.create(this, previousState);
494
+ if (this._diffCache && this._diffCache.previousId === previousState?.id) return this._diffCache.diff;
495
+ const diff = await Diff.create(this, previousState);
496
+ this._diffCache = { previousId: previousState?.id, diff };
497
+ return diff;
502
498
  }
503
499
 
504
500
  async toToolResult(previousState: ActionResult | null, locator: string): Promise<ToolResultMetadata> {
@@ -549,7 +545,18 @@ export class ActionResult implements ActionResultData {
549
545
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
550
546
  }
551
547
 
552
- if (diff.htmlParts.length > 0) {
548
+ if (this.overlay.present && (!previousState.overlay.present || previousState.overlay.name !== this.overlay.name)) {
549
+ pageDiff.areaOfInterest = this.overlay.describe();
550
+ }
551
+
552
+ if (pageDiff.areaOfInterest && this.overlay.html && this.overlay.root) {
553
+ const htmlConfig = ConfigParser.getInstance().getConfig().html;
554
+ let subtree = await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined));
555
+ if (subtree.length > HTML_PART_SUBTREE_BUDGET) {
556
+ subtree = `${subtree.slice(0, HTML_PART_SUBTREE_BUDGET)}...<!-- truncated -->`;
557
+ }
558
+ pageDiff.htmlParts = [{ container: this.overlay.root, subtree, rawSize: subtree.length, added: [], removed: [] }];
559
+ } else if (diff.isSameUrl() && diff.htmlParts.length > 0) {
553
560
  const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
554
561
  if (collapsed.length > 0) {
555
562
  pageDiff.htmlParts = collapsed;
@@ -565,6 +572,29 @@ export class ActionResult implements ActionResultData {
565
572
  return result;
566
573
  }
567
574
 
575
+ private computeStateHash(includeRegion: boolean): string {
576
+ const parts: string[] = [];
577
+
578
+ parts.push(this.relativeUrl || this.url || '/');
579
+
580
+ this.extractHeadings(this.html);
581
+
582
+ if (this.h1) parts.push(`h1_${this.h1}`);
583
+ if (this.h2) parts.push(`h2_${this.h2}`);
584
+ if (includeRegion && this.overlay.present && this.overlay.name) parts.push(`region_${this.overlay.name}`);
585
+
586
+ let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
587
+
588
+ if (stateString.length > 200) {
589
+ stateString = stateString.substring(0, 200);
590
+ if (stateString.endsWith('_')) {
591
+ stateString = stateString.slice(0, -1);
592
+ }
593
+ }
594
+
595
+ return stateString;
596
+ }
597
+
568
598
  private consoleErrors(): string[] {
569
599
  const errors: string[] = [];
570
600
 
@@ -681,15 +711,24 @@ export class Diff {
681
711
  return this._messages;
682
712
  }
683
713
 
714
+ get similarity(): number {
715
+ return this._htmlDiffResult?.similarity ?? 0;
716
+ }
717
+
718
+ get pageSize(): number {
719
+ return this._htmlDiffResult?.pageSize ?? 0;
720
+ }
721
+
684
722
  async calculate(): Promise<void> {
685
723
  if (!this.previous) return;
686
724
 
725
+ this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
726
+
687
727
  if (!this._isSameUrl) {
688
728
  this._messages = liveRegionMessages(this.previous.html, this.current.html);
689
729
  return;
690
730
  }
691
731
 
692
- this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
693
732
  this._messages = this._htmlDiffResult.messages;
694
733
 
695
734
  const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
package/src/action.ts CHANGED
@@ -11,9 +11,9 @@ import { Observability } from './observability.ts';
11
11
  import type { PlaywrightRecorder } from './playwright-recorder.ts';
12
12
  import type { StateManager } from './state-manager.js';
13
13
  import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
14
- import { captureHtmlForSnapshot, getVisibleOverlayHtmlExtractorSource, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
14
+ import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
15
15
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
- import { Overlay } from './utils/overlay.js';
16
+ import { Overlay, OverlayPage } from './utils/overlay.js';
17
17
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
18
18
  import { safeFilename } from './utils/strings.ts';
19
19
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
@@ -146,13 +146,11 @@ class Action {
146
146
  let ariaSnapshot: string | null = null;
147
147
  let ariaSnapshotFile: string | undefined = undefined;
148
148
  let focusedElement: FocusedElement | null = null;
149
- let overlayHtml = '';
150
149
 
151
150
  try {
152
151
  const page = this.playwrightHelper.page;
153
152
  ariaSnapshot = await page.locator('body').ariaSnapshot();
154
153
  focusedElement = await page.evaluate(readFocusedElement);
155
- if (!frame) overlayHtml = await this.captureOverlayHtml();
156
154
  } catch (err) {
157
155
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
158
156
  }
@@ -181,9 +179,9 @@ class Action {
181
179
  ariaSnapshot,
182
180
  ariaSnapshotFile,
183
181
  focusedElement,
184
- overlayHtml: overlayHtml || undefined,
185
182
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
186
183
  });
184
+ if (!frame) await this.detectRegionOfInterest(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
187
185
  this.stateManager.updateState(result, codeBlock);
188
186
  return result;
189
187
  } catch (err) {
@@ -195,14 +193,64 @@ class Action {
195
193
  }
196
194
  }
197
195
 
198
- private async captureOverlayHtml(): Promise<string> {
199
- return this.playwrightHelper.page.evaluate(
200
- ({ extractorSource, config }: { extractorSource: string; config: any }) => {
201
- const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
202
- return extract(config);
203
- },
204
- { extractorSource: getVisibleOverlayHtmlExtractorSource(), config: Overlay.captureConfig() }
205
- );
196
+ private async detectRegionOfInterest(result: ActionResult): Promise<void> {
197
+ const previousState = this.stateManager.getCurrentState();
198
+ if (!previousState) return;
199
+ const previous = ActionResult.fromState(previousState);
200
+ const previousOverlay = previous.overlay;
201
+ const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
202
+ const overlayPage = new OverlayPage(this.playwrightHelper.page);
203
+
204
+ if (result.overlay.detected && previousOverlay.detected && previousOverlay.root && previousOverlay.type === result.overlay.type && previousOverlay.name === result.overlay.name) {
205
+ result.overlay = previousOverlay;
206
+ return;
207
+ }
208
+
209
+ if (!previous.html) return;
210
+
211
+ if (previous.html === result.html) {
212
+ if (sameUrl && previousOverlay.present && previousOverlay.xpath && !result.overlay.detected) result.overlay = previousOverlay;
213
+ return;
214
+ }
215
+
216
+ let carried: Overlay | null = null;
217
+ if (sameUrl && previousOverlay.present && previousOverlay.xpath) {
218
+ if (await overlayPage.isStillOpen(previousOverlay)) {
219
+ carried = previousOverlay;
220
+ } else {
221
+ debugLog(`Region closed: ${previousOverlay.name || previousOverlay.type}`);
222
+ if (!result.overlay.detected) {
223
+ const parent = previousOverlay.parent;
224
+ if (parent?.xpath) {
225
+ const restored = new Overlay(parent);
226
+ if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
227
+ }
228
+ return;
229
+ }
230
+ }
231
+ }
232
+
233
+ const diff = await result.diff(previous);
234
+ const detected = await overlayPage.detectRegion({
235
+ parts: diff.htmlParts,
236
+ pageSize: diff.pageSize,
237
+ similarity: diff.similarity,
238
+ sameUrl,
239
+ previousHtml: previous.html,
240
+ });
241
+
242
+ if (result.overlay.detected) {
243
+ if (detected) result.overlay = result.overlay.withGeometry(detected);
244
+ return;
245
+ }
246
+
247
+ if (detected) {
248
+ result.overlay = detected;
249
+ if (carried) result.overlay = detected.withParent(carried);
250
+ return;
251
+ }
252
+
253
+ if (carried) result.overlay = carried;
206
254
  }
207
255
 
208
256
  private async captureMainDocumentStatus(): Promise<number | undefined> {
@@ -381,6 +429,13 @@ class Action {
381
429
  await recorder.reset();
382
430
  await recorder.start();
383
431
  }
432
+ if (executedSteps.length > 0) {
433
+ codeString = executedSteps.map((step) => step.command).join('\n');
434
+ }
435
+ if (!isFatalBrowserError(err)) {
436
+ const captured = await this.captureOnce({ codeBlock: codeString }).catch(() => null);
437
+ if (captured && !captured.error) this.actionResult = captured;
438
+ }
384
439
  this.assertionSteps = [];
385
440
  throw err;
386
441
  } finally {
@@ -491,11 +546,29 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
491
546
  type StepListener = (step: any, error?: any) => void;
492
547
 
493
548
  export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
549
+ const recorded = new WeakMap<object, ExecutedStep>();
550
+ let batchFailed = false;
494
551
  const listener: StepListener = (step, error) => {
495
552
  if (!step?.toCode) return;
496
553
  if (step.name?.startsWith('grab')) return;
554
+
555
+ const existing = recorded.get(step);
556
+ if (existing) {
557
+ if (!error && !existing.success) {
558
+ existing.success = true;
559
+ existing.error = undefined;
560
+ batchFailed = target.some((entry) => !entry.success);
561
+ }
562
+ return;
563
+ }
564
+ if (batchFailed) return;
565
+
497
566
  const executed: ExecutedStep = { command: step.toCode(), success: !error };
498
- if (error) executed.error = errorToString(error);
567
+ if (error) {
568
+ executed.error = errorToString(error);
569
+ batchFailed = true;
570
+ }
571
+ recorded.set(step, executed);
499
572
  target.push(executed);
500
573
  if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
501
574
  assertionsTarget.push({ name: step.name, args: step.args || [] });
package/src/ai/captain.ts CHANGED
@@ -160,7 +160,7 @@ export class Captain extends CaptainBase implements Agent {
160
160
  const headingsBlock = headingLines.join('\n');
161
161
 
162
162
  let pageSummary = '';
163
- const cachedResearch = Researcher.getCachedResearch(state);
163
+ const cachedResearch = Researcher.getCachedResearch(actionResult);
164
164
  if (cachedResearch) {
165
165
  pageSummary = `<page_summary>\n${this.explorBot.agentResearcher().extractBrief(cachedResearch)}\n</page_summary>`;
166
166
  }
package/src/ai/driller.ts CHANGED
@@ -6,23 +6,9 @@ import { setActivity } from '../activity.ts';
6
6
  import { Observability } from '../observability.ts';
7
7
  import { Plan, Test, TestResult } from '../test-plan.ts';
8
8
  import { collectInteractiveNodes } from '../utils/aria.ts';
9
- import {
10
- EXPLORBOT_ATTRS,
11
- HTML_COMPOSITE_AREA_HINTS,
12
- HTML_COMPOSITE_TARGET_ROLES,
13
- HTML_EXTRACTION_LIMITS,
14
- HTML_FORM_CONTROL_ROLES,
15
- HTML_FORM_CONTROL_TAGS,
16
- HTML_INTERACTIVE_ROLES,
17
- HTML_SELECTORS,
18
- HTML_VISIBILITY_LIMITS,
19
- getComponentScopeHtmlExtractorSource,
20
- getVisibleOverlayHtmlExtractorSource,
21
- inferHtmlRole,
22
- } from '../utils/html.ts';
9
+ import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from '../utils/html.ts';
23
10
  import { createDebug, tag } from '../utils/logger.ts';
24
11
  import { loop, pause } from '../utils/loop.ts';
25
- import { OVERLAY_SELECTORS } from '../utils/overlay.ts';
26
12
  import { annotatePageElements } from '../utils/web-annotate.ts';
27
13
  import { eidxInContainer } from '../utils/web-eidx.ts';
28
14
  import { WebElement } from '../utils/web-element.ts';
@@ -648,8 +634,11 @@ export class Driller extends TaskAgent implements Agent {
648
634
  private async detectNestedOverlayContext(component: ComponentInfo, result: any): Promise<string | null> {
649
635
  if (!result?.pageDiff?.ariaChanges || result.pageDiff.urlChanged) return null;
650
636
 
651
- const overlayHtml = await this.getVisibleOverlayHtml();
652
- if (!overlayHtml) return null;
637
+ const parts = result.pageDiff.htmlParts ?? [];
638
+ let appeared = parts.filter((part: any) => part.added?.length > 0);
639
+ if (result.pageDiff.areaOfInterest) appeared = parts;
640
+ const appearedHtml = appeared.map((part: any) => part.subtree).join('\n');
641
+ if (!appearedHtml) return null;
653
642
 
654
643
  const state = this.stateManager.getCurrentState();
655
644
  if (!state) return null;
@@ -661,7 +650,7 @@ export class Driller extends TaskAgent implements Agent {
661
650
  Keep the recorded code reusable and include the parent-opening action when the nested element requires the overlay to be open.
662
651
 
663
652
  <overlay_html>
664
- ${overlayHtml}
653
+ ${appearedHtml}
665
654
  </overlay_html>
666
655
 
667
656
  <current_page_aria>
@@ -671,27 +660,6 @@ export class Driller extends TaskAgent implements Agent {
671
660
  `;
672
661
  }
673
662
 
674
- private async getVisibleOverlayHtml(): Promise<string> {
675
- return this.explorer.withPage((page) =>
676
- page.evaluate(
677
- ({ extractorSource, config }) => {
678
- const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
679
- return extract(config);
680
- },
681
- {
682
- extractorSource: getVisibleOverlayHtmlExtractorSource(),
683
- config: {
684
- interactiveContentSelector: HTML_SELECTORS.interactiveContent,
685
- limits: HTML_EXTRACTION_LIMITS,
686
- overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
687
- overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
688
- visibilityLimits: HTML_VISIBILITY_LIMITS,
689
- },
690
- }
691
- )
692
- );
693
- }
694
-
695
663
  private async getComponentScopeHtml(component: ComponentInfo, originalState: ActionResult): Promise<string> {
696
664
  const scopedHtml = await this.explorer.withPage((page) =>
697
665
  page.evaluate(
@@ -2,16 +2,28 @@ import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import type { ApiClient } from '../api/api-client.ts';
5
+ import type { RequestResult } from '../api/request-result.ts';
5
6
  import type { RequestStore } from '../api/request-store.ts';
6
7
  import { extractEndpointDefinition } from '../api/spec-reader.ts';
7
8
  import { tag } from '../utils/logger.ts';
9
+ import { RequestMap } from '../utils/request-map.ts';
10
+ import { isDynamicSegment } from '../utils/url-matcher.ts';
8
11
 
9
12
  export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
10
13
  let finished = false;
11
- let result: FishermanResult = { success: false, summary: '', created: [], failed: [] };
14
+ let result: FishermanResult | null = null;
15
+ const ledgerStart = requestStore.getMadeRequests().length;
12
16
 
13
- const getResult = () => result;
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);
14
20
  const isFinished = () => finished;
21
+ const finishFromText = (text?: string) => {
22
+ finished = true;
23
+ const synthesized = synthesizeResult(runRequests(), successfulWrites(), true);
24
+ if (text && synthesized.success) synthesized.summary = text;
25
+ result = synthesized;
26
+ };
15
27
 
16
28
  const tools = {
17
29
  getEndpointSpec: tool({
@@ -76,7 +88,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
76
88
  request: tool({
77
89
  description: dedent`
78
90
  Make an HTTP request to the API.
79
- Returns status, timing, and auto-extracted IDs and names from the response.
91
+ Returns status, plus IDs and names auto-extracted from the response under 'extracted'.
80
92
  `,
81
93
  inputSchema: z.object({
82
94
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
@@ -119,7 +131,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
119
131
  return {
120
132
  success: true,
121
133
  status: reqResult.status,
122
- ...extracted,
134
+ extracted,
123
135
  };
124
136
  },
125
137
  }),
@@ -148,9 +160,32 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
148
160
  .describe('List of items that could not be created'),
149
161
  }),
150
162
  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));
185
+
151
186
  tag('success').log(`Fisherman done: ${summary}`);
152
187
  finished = true;
153
- result = { success: true, summary, created, failed: failed || [] };
188
+ result = { success: true, summary, created: verified, failed: failed || [] };
154
189
  return { finished: true };
155
190
  },
156
191
  }),
@@ -169,7 +204,21 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
169
204
  }),
170
205
  };
171
206
 
172
- return { tools, getResult, isFinished };
207
+ return { tools, getResult, isFinished, finishFromText };
208
+ }
209
+
210
+ function synthesizeResult(made: RequestResult[], writes: RequestResult[], declaredDone: boolean): FishermanResult {
211
+ const failures = made.filter((r) => r.status >= 400 || r.error);
212
+ let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
213
+ const lastFailure = failures[failures.length - 1];
214
+ if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
215
+ return { success: declaredDone && writes.length > 0, summary, created: writes.map(toCreatedItem), failed: [] };
216
+ }
217
+
218
+ function toCreatedItem(write: RequestResult): FishermanResult['created'][number] {
219
+ const { id, title } = write.extractIdAndTitle();
220
+ const segments = write.path.split('/').filter((s) => s && !isDynamicSegment(s));
221
+ return { type: segments[segments.length - 1] || 'item', id, title, request: write.toEndpoint() };
173
222
  }
174
223
 
175
224
  function responseCategory(status: number): ResponseCategory {
@@ -209,7 +258,7 @@ function extractKeyFields(body: any, result: Record<string, any> = {}, depth = 0
209
258
  export interface FishermanResult {
210
259
  success: boolean;
211
260
  summary: string;
212
- created: Array<{ type: string; id?: string | number; title?: string }>;
261
+ created: Array<{ type: string; id?: string | number; title?: string; request?: string }>;
213
262
  failed: Array<{ type: string; reason: string }>;
214
263
  }
215
264