explorbot 0.2.5 → 0.3.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 (89) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/conversation.d.ts +1 -0
  17. package/dist/src/ai/conversation.js +3 -0
  18. package/dist/src/ai/driller.js +6 -2
  19. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  20. package/dist/src/ai/fisherman-tools.js +39 -0
  21. package/dist/src/ai/fisherman.js +3 -2
  22. package/dist/src/ai/navigator.d.ts +2 -1
  23. package/dist/src/ai/navigator.js +5 -9
  24. package/dist/src/ai/pilot.js +51 -29
  25. package/dist/src/ai/planner/subpages.js +2 -16
  26. package/dist/src/ai/planner.js +1 -1
  27. package/dist/src/ai/provider.d.ts +3 -0
  28. package/dist/src/ai/provider.js +80 -17
  29. package/dist/src/ai/researcher/cache.d.ts +8 -3
  30. package/dist/src/ai/researcher/cache.js +13 -8
  31. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  32. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  33. package/dist/src/ai/researcher.js +4 -3
  34. package/dist/src/ai/rules.js +1 -5
  35. package/dist/src/ai/tester.d.ts +1 -1
  36. package/dist/src/ai/tester.js +34 -26
  37. package/dist/src/ai/tools.d.ts +8 -5
  38. package/dist/src/ai/tools.js +79 -56
  39. package/dist/src/commands/explore-command.js +22 -17
  40. package/dist/src/commands/init-command.js +13 -20
  41. package/dist/src/config.d.ts +2 -1
  42. package/dist/src/config.js +3 -1
  43. package/dist/src/experience-tracker.d.ts +2 -0
  44. package/dist/src/experience-tracker.js +12 -0
  45. package/dist/src/explorbot.js +1 -1
  46. package/dist/src/playwright-recorder.js +6 -12
  47. package/dist/src/test-plan.d.ts +8 -0
  48. package/dist/src/test-plan.js +11 -0
  49. package/dist/src/utils/html-diff.d.ts +5 -0
  50. package/dist/src/utils/html-diff.js +65 -6
  51. package/dist/src/utils/strings.d.ts +2 -0
  52. package/dist/src/utils/strings.js +32 -0
  53. package/dist/src/utils/url-matcher.d.ts +1 -0
  54. package/dist/src/utils/url-matcher.js +31 -2
  55. package/docs/basics/getting-started.md +33 -10
  56. package/docs/basics/providers.md +6 -4
  57. package/docs/contributing/npm-package.md +73 -4
  58. package/models.json +4 -4
  59. package/package.json +6 -2
  60. package/src/action-result.ts +61 -16
  61. package/src/action.ts +51 -17
  62. package/src/ai/captain/web-mode.ts +1 -2
  63. package/src/ai/captain.ts +9 -1
  64. package/src/ai/conversation.ts +3 -0
  65. package/src/ai/driller.ts +6 -2
  66. package/src/ai/fisherman-tools.ts +35 -0
  67. package/src/ai/fisherman.ts +3 -2
  68. package/src/ai/navigator.ts +6 -10
  69. package/src/ai/pilot.ts +54 -32
  70. package/src/ai/planner/subpages.ts +2 -13
  71. package/src/ai/planner.ts +1 -1
  72. package/src/ai/provider.ts +111 -41
  73. package/src/ai/researcher/cache.ts +17 -9
  74. package/src/ai/researcher/deep-analysis.ts +1 -1
  75. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  76. package/src/ai/researcher.ts +4 -3
  77. package/src/ai/rules.ts +1 -5
  78. package/src/ai/tester.ts +32 -27
  79. package/src/ai/tools.ts +84 -60
  80. package/src/commands/explore-command.ts +17 -14
  81. package/src/commands/init-command.ts +14 -20
  82. package/src/config.ts +4 -2
  83. package/src/experience-tracker.ts +13 -0
  84. package/src/explorbot.ts +1 -1
  85. package/src/playwright-recorder.ts +6 -11
  86. package/src/test-plan.ts +18 -0
  87. package/src/utils/html-diff.ts +72 -7
  88. package/src/utils/strings.ts +36 -0
  89. package/src/utils/url-matcher.ts +27 -2
@@ -6,7 +6,7 @@ Explorbot develops on Bun but ships to npm as a Node.js-compatible package. This
6
6
 
7
7
  - Bun (for development and running the build)
8
8
  - Node.js >= 24 (for verifying the build output)
9
- - npm account with publish access to `explorbot` package
9
+ - npm account with publish access to `explorbot` and `prima-cli`, for publishing by hand; releases go out over OIDC (see [Trusted Publishing and Provenance](#trusted-publishing-and-provenance))
10
10
 
11
11
  ## How the Build Works
12
12
 
@@ -90,14 +90,16 @@ Key `package.json` fields:
90
90
  "boat/prima/src/**/*.ts",
91
91
  "boat/prima/bin/**/*.ts",
92
92
  "boat/prima/package.json",
93
+ "boat/prima/README.md",
93
94
  "rules/",
94
- "assets/sample-files/"
95
+ "assets/sample-files/",
96
+ "models.json"
95
97
  ],
96
98
  "engines": { "node": ">=24.0.0" }
97
99
  }
98
100
  ```
99
101
 
100
- The package ships two commands: `explorbot`, and `prima` for the [prima boat](../reference/commands.md#prima-boat), so `npx -p explorbot prima <command>` works without a separate install.
102
+ The package ships two commands, `explorbot` and `prima` for the [prima boat](../reference/commands.md#prima-boat). Prima is also mounted as a subcommand, so `npx explorbot prima <command>`, `npx -p explorbot prima <command>` and the standalone [`prima-cli`](#publishing-prima-cli) package all reach the same code.
101
103
 
102
104
  Explorbot is both a CLI (`bin`) and a library (`exports`). The `.` entry point is `src/index.ts`, a side-effect-free barrel that re-exports the public API (`ExplorBot`, `Plan`, `Test`, and their types). The `exports` conditions are ordered so each consumer gets the right entry: `types` (the emitted `.d.ts`) for type-checking, `bun` (the TypeScript source) under Bun, and `import` (the compiled JS) under Node.js. This is why the source `src/**` files ship alongside `dist/`.
103
105
 
@@ -127,6 +129,73 @@ npm version patch # or minor, major
127
129
  npm publish
128
130
  ```
129
131
 
132
+ ## Publishing prima-cli
133
+
134
+ Prima ships three ways, all the same code:
135
+
136
+ | | |
137
+ |---|---|
138
+ | `npx prima-cli` | its own package |
139
+ | `npx explorbot prima` | subcommand of the explorbot CLI |
140
+ | `npx -p explorbot prima` | the `prima` bin explorbot installs |
141
+
142
+ Prima is compiled into `dist/` by the same `tsc` run as everything else; only the packaging differs. `bun run build:prima` (`scripts/build-prima-npm.ts`) runs after `build:npm` and stages a second package:
143
+
144
+ ```
145
+ dist-prima/
146
+ ├── package.json # boat/prima/package.json + version and dependencies from the root manifest
147
+ ├── README.md # boat/prima/README.md, the npm page
148
+ └── dist/
149
+ ├── boat/prima/
150
+ ├── src/
151
+ ├── models.json
152
+ ├── rules/
153
+ └── assets/sample-files/
154
+ ```
155
+
156
+ The `dist/` layout is copied, not flattened: `config.js` reads `../models.json`, `rules-loader.js` reads `../../rules`, and `tester.js` reads `../../assets/sample-files`. Dependencies are copied verbatim from the root manifest rather than pruned to prima's closure — pruning saves little next to playwright and codeceptjs, and breaks on the first moved import. Edit `boat/prima/package.json` for the package name, bin, keywords or engines.
157
+
158
+ `publish-prima.yml` publishes it when a GitHub release is **published**, so a draft ships nothing. The version is the release tag with `prima-` and a leading `v` stripped: `0.2.6`, `v0.2.6` and `prima-v0.2.6` all publish `prima-cli@0.2.6`. A `prima-v*` tag is how prima ships on its own — `publish.yml` ignores it. A pre-release release, or a version containing `beta`, `alpha`, `pre` or `rc`, goes to the `beta` dist-tag.
159
+
160
+ Before publishing, the workflow packs the staged package, installs the tarball into an empty directory and runs it there with an empty `HOME` — a real consumer install, which is what catches a missing file, dependency or asset. It skips a version already on the registry, so a failed run can be re-run.
161
+
162
+ To check it locally:
163
+
164
+ ```bash
165
+ bun run build:npm && bun run build:prima
166
+ npm pack ./dist-prima --pack-destination /tmp
167
+ cd $(mktemp -d) && npm init -y && npm install --ignore-scripts /tmp/prima-cli-*.tgz
168
+ ./node_modules/.bin/prima-cli --help
169
+ ```
170
+
171
+ ## Trusted Publishing and Provenance
172
+
173
+ Both packages publish over OIDC, with no npm token in the repository. npm checks GitHub's identity token against a trusted publisher registered on the package, then attaches a provenance attestation. No `--provenance` flag is needed; trusted publishing does it.
174
+
175
+ The workflows are already set up for this: `id-token: write`, `ubuntu-latest`, and `npm@latest` for the npm 11.5.1+ requirement. The rest is per package on npmjs.com, because a trusted publisher names one package and one workflow file — `explorbot`'s does not cover `prima-cli`. The package has to exist before it can be configured, which is why a new one is claimed with a manual publish first.
176
+
177
+ On the package's **Settings** tab, under **Trusted Publisher**, choose **GitHub Actions**:
178
+
179
+ | Field | `explorbot` | `prima-cli` |
180
+ |---|---|---|
181
+ | Organization or user | `testomatio` | `testomatio` |
182
+ | Repository | `explorbot` | `explorbot` |
183
+ | Workflow filename | `publish.yml` | `publish-prima.yml` |
184
+ | Allowed actions | `npm publish` | `npm publish` |
185
+ | Environment name | empty | empty |
186
+
187
+ The workflow filename is a basename, and it is the field that differs — pointing `prima-cli` at `publish.yml` fails every release. Leave the environment empty unless the publish job gains an `environment:` key; a mismatch fails the publish.
188
+
189
+ To check a publish was attested:
190
+
191
+ ```bash
192
+ npm view <package>@<version> dist.attestations
193
+ ```
194
+
195
+ A `slsa.dev/provenance/v1` predicate means it worked. Nothing printed means the version went out unattested. `npm audit signatures` checks an installed tree.
196
+
197
+ Once no workflow needs a token, revoke the package's automation tokens and set its publishing access to require two-factor authentication and disallow tokens.
198
+
130
199
  ## Known Limitations
131
200
 
132
201
  - **Type declarations are transform-generated** - Declarations come from a transformed copy of the source (see [Type Declarations](#type-declarations)), not from `tsc --declaration` directly, because the mixin-based agents can't emit declarations as written. The published `.d.ts` types are exact; the workaround only concerns how they're produced.
@@ -135,4 +204,4 @@ npm publish
135
204
 
136
205
  The `test.yml` workflow verifies the npm build on every push. On Node.js 24 it runs `bun run build:npm`, then the Node smoke tests: `node --test tests/node/*.mjs`. The `publish.yml` workflow additionally checks `node dist/bin/explorbot-cli.js --help` before publishing.
137
206
 
138
- The `publish.yml` workflow publishes to npm when you push a version tag (`v*` or a bare `1.2.3`-style tag). It overwrites the package version from the tag; tags containing `beta`, `alpha`, `pre`, or `rc` publish to the `beta` dist-tag instead of `latest`.
207
+ The `publish.yml` workflow publishes `explorbot` when you push a version tag (`v*` or a bare `1.2.3`-style tag). It overwrites the package version from the tag; tags containing `beta`, `alpha`, `pre`, or `rc` publish to the `beta` dist-tag instead of `latest`. `publish-prima.yml` publishes `prima-cli` when a GitHub release is published. Both go out over OIDC and with provenance — see [Publishing prima-cli](#publishing-prima-cli) and [Trusted Publishing and Provenance](#trusted-publishing-and-provenance).
package/models.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "openrouter": {
3
3
  "model": "openai/gpt-oss-20b:nitro",
4
- "visionModel": "google/gemma-4-31b-it:nitro",
5
- "agenticModel": "google/gemma-4-31b-it:nitro"
4
+ "visionModel": "openai/gpt-5.6-luna",
5
+ "agenticModel": "openai/gpt-5.6-luna"
6
6
  },
7
7
  "poolside": {
8
8
  "model": "poolside/laguna-xs-2.1"
@@ -13,8 +13,8 @@
13
13
  "agenticModel": "qwen/qwen3.6-27b"
14
14
  },
15
15
  "openai": {
16
- "model": "gpt-5.4-nano",
17
- "visionModel": "gpt-5.4-nano",
16
+ "model": "gpt-5-nano",
17
+ "visionModel": "gpt-5.6-luna",
18
18
  "agenticModel": "gpt-5.6-luna"
19
19
  },
20
20
  "anthropic": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -30,6 +30,7 @@
30
30
  "boat/prima/src/**/*.ts",
31
31
  "boat/prima/bin/**/*.ts",
32
32
  "boat/prima/package.json",
33
+ "boat/prima/README.md",
33
34
  "rules/",
34
35
  "assets/sample-files/",
35
36
  "models.json"
@@ -58,7 +59,8 @@
58
59
  "lint:fix": "biome lint --write .",
59
60
  "check": "biome check .",
60
61
  "check:fix": "biome check --write .",
61
- "langfuse:export": "bun run .claude/skills/explorbot-debug/langfuse-export.ts"
62
+ "langfuse:export": "bun run .claude/skills/explorbot-debug/langfuse-export.ts",
63
+ "build:prima": "bun run scripts/build-prima-npm.ts"
62
64
  },
63
65
  "keywords": [
64
66
  "cli",
@@ -97,6 +99,7 @@
97
99
  "ai": "^7.0.2",
98
100
  "axe-core": "^4.11.1",
99
101
  "bash-tool": "^1.3.15",
102
+ "chalk": "^5.6.2",
100
103
  "cli-highlight": "^2.1.11",
101
104
  "codeceptjs": "4.0.0-rc.16",
102
105
  "commander": "^14.0.1",
@@ -121,6 +124,7 @@
121
124
  "parse5": "^8.0.0",
122
125
  "pixelmatch": "^7.2.0",
123
126
  "playwright": "^1.62",
127
+ "playwright-core": "^1.62",
124
128
  "pngjs": "^7.0.0",
125
129
  "react": "^19.1.1",
126
130
  "sambanova-ai-provider": "^1.2.2",
@@ -4,7 +4,7 @@ import { ConfigParser, type HtmlConfig, outputPath } from './config.ts';
4
4
  import type { Link, WebPageState } from './state-manager.ts';
5
5
  import { LARGE_ARIA_CHANGE_THRESHOLD, compactAriaSnapshot, diffAriaSnapshots } from './utils/aria.ts';
6
6
  import { TTLCache } from './utils/cache.ts';
7
- import { type HtmlDiffPart, type HtmlDiffResult, htmlDiff } from './utils/html-diff.ts';
7
+ import { type HtmlDiffPart, type HtmlDiffResult, htmlDiff, liveRegionMessages } from './utils/html-diff.ts';
8
8
  import { extractHeadings, extractLinks, extractTargetedHtml, htmlCombinedSnapshot, htmlMinimalUISnapshot, htmlTextSnapshot, minifyHtml } from './utils/html.ts';
9
9
  import { createDebug } from './utils/logger.ts';
10
10
  import { slugify } from './utils/strings.ts';
@@ -27,6 +27,7 @@ interface ActionResultData extends WebPageState {
27
27
  h3?: string | undefined;
28
28
  h4?: string | undefined;
29
29
  browserLogs?: any[];
30
+ networkRequests?: NetworkCall[];
30
31
  iframeSnapshots?: Array<{ src: string; html: string; id?: string }>;
31
32
  ariaSnapshot?: string | null;
32
33
  ariaSnapshotFile?: string;
@@ -41,6 +42,9 @@ export interface PageDiff {
41
42
  currentUrl: string;
42
43
  ariaChanges?: string | null;
43
44
  ariaChangeCount?: number;
45
+ messages?: string[];
46
+ requests?: NetworkCall[];
47
+ consoleErrors?: string[];
44
48
  htmlParts?: HtmlDiffPart[];
45
49
  iframes?: string;
46
50
  }
@@ -66,6 +70,7 @@ export class ActionResult implements ActionResultData {
66
70
  public url = '';
67
71
  public fullUrl: string | undefined = undefined;
68
72
  public browserLogs: any[] = [];
73
+ public networkRequests: NetworkCall[] = [];
69
74
  public iframeSnapshots: Array<{ src: string; html: string; id?: string }> = [];
70
75
  public iframeURL: string | undefined = undefined;
71
76
  readonly screenshotFile: string | undefined = undefined;
@@ -91,6 +96,7 @@ export class ActionResult implements ActionResultData {
91
96
  this.httpStatus = data.httpStatus;
92
97
  this.error = data.error ?? null;
93
98
  this.browserLogs = data.browserLogs ?? [];
99
+ this.networkRequests = data.networkRequests ?? [];
94
100
  this.iframeSnapshots = data.iframeSnapshots ?? [];
95
101
  this.iframeURL = data.iframeURL;
96
102
  this.notes = data.notes ?? [];
@@ -508,23 +514,30 @@ export class ActionResult implements ActionResultData {
508
514
  return result;
509
515
  }
510
516
 
511
- const urlChanged = previousState ? !this.isSameUrl({ url: previousState.url }) : true;
517
+ const pageDiff: PageDiff = {
518
+ urlChanged: previousState ? !this.isSameUrl({ url: previousState.url }) : true,
519
+ currentUrl: this.url,
520
+ };
521
+ result.pageDiff = pageDiff;
512
522
 
513
- if (!previousState) {
514
- result.pageDiff = {
515
- urlChanged: true,
516
- currentUrl: this.url,
517
- };
518
- return result;
523
+ if (this.networkRequests.length > 0) {
524
+ pageDiff.requests = this.networkRequests;
525
+ }
526
+
527
+ const consoleErrors = this.consoleErrors();
528
+ if (consoleErrors.length > 0) {
529
+ pageDiff.consoleErrors = consoleErrors;
519
530
  }
520
531
 
532
+ if (!previousState) return result;
533
+
534
+ pageDiff.previousUrl = previousState.url;
535
+
521
536
  const diff = await this.diff(previousState);
522
537
 
523
- const pageDiff: PageDiff = {
524
- urlChanged,
525
- previousUrl: previousState.url,
526
- currentUrl: this.url,
527
- };
538
+ if (diff.messages.length > 0) {
539
+ pageDiff.messages = diff.messages;
540
+ }
528
541
 
529
542
  if (diff.ariaChanged) {
530
543
  pageDiff.ariaChanges = diff.ariaChanged;
@@ -552,11 +565,28 @@ export class ActionResult implements ActionResultData {
552
565
  }
553
566
  }
554
567
 
555
- result.pageDiff = pageDiff;
556
568
  return result;
557
569
  }
570
+
571
+ private consoleErrors(): string[] {
572
+ const errors: string[] = [];
573
+
574
+ for (const log of this.browserLogs) {
575
+ if ((log.type || log.level) !== 'error') continue;
576
+ const text = String(log.text || log.message || log).trim();
577
+ if (!text) continue;
578
+ if (errors.includes(text)) continue;
579
+ errors.push(text.slice(0, CONSOLE_ERROR_MAX_LENGTH));
580
+ if (errors.length === CONSOLE_ERROR_LIMIT) break;
581
+ }
582
+
583
+ return errors;
584
+ }
558
585
  }
559
586
 
587
+ const CONSOLE_ERROR_MAX_LENGTH = 300;
588
+ const CONSOLE_ERROR_LIMIT = 3;
589
+
560
590
  const HTML_PARTS_TOTAL_BUDGET = 8000;
561
591
  const HTML_PARTS_COUNT_LIMIT = 8;
562
592
  const HTML_PART_SUBTREE_BUDGET = 2000;
@@ -584,6 +614,7 @@ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
584
614
 
585
615
  export class Diff {
586
616
  private _htmlDiffResult: HtmlDiffResult | null = null;
617
+ private _messages: string[] = [];
587
618
  private _ariaDiffResult: string | null = null;
588
619
  private _ariaChangeCount = 0;
589
620
  private _isSameUrl: boolean;
@@ -636,19 +667,33 @@ export class Diff {
636
667
  return this._htmlDiffResult;
637
668
  }
638
669
 
670
+ get messages(): string[] {
671
+ return this._messages;
672
+ }
673
+
639
674
  async calculate(): Promise<void> {
640
675
  if (!this.previous) return;
641
676
 
642
- if (this._isSameUrl) {
643
- this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
677
+ if (!this._isSameUrl) {
678
+ this._messages = liveRegionMessages(this.previous.html, this.current.html);
679
+ return;
644
680
  }
645
681
 
682
+ this._htmlDiffResult = await htmlDiff(this.previous.html, this.current.html, ConfigParser.getInstance().getConfig().html);
683
+ this._messages = this._htmlDiffResult.messages;
684
+
646
685
  const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
647
686
  this._ariaDiffResult = ariaDiff.text;
648
687
  this._ariaChangeCount = ariaDiff.count;
649
688
  }
650
689
  }
651
690
 
691
+ export interface NetworkCall {
692
+ method: string;
693
+ path: string;
694
+ status: number;
695
+ }
696
+
652
697
  export interface FocusedElement {
653
698
  role: string;
654
699
  name: string;
package/src/action.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { context, trace } from '@opentelemetry/api';
4
4
  import { container, recorder } from 'codeceptjs';
5
5
  import * as codeceptjs from 'codeceptjs';
6
- import { ActionResult, type FocusedElement } from './action-result.js';
6
+ import { ActionResult, type FocusedElement, type NetworkCall } from './action-result.js';
7
7
  import { clearActivity, setActivity } from './activity.ts';
8
8
  import { ConfigParser, outputPath } from './config.js';
9
9
  import type { ExplorbotConfig } from './config.js';
@@ -21,6 +21,8 @@ const debugLog = createDebug('explorbot:action');
21
21
  const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
22
22
  const DEFAULT_ACTION_TIMEOUT = 3000;
23
23
  const DEFAULT_PAGE_TIMEOUT = 3000;
24
+ const MAX_NETWORK_CALLS = 10;
25
+ const IMPORTANT_LOG_LEVELS = new Set(['info', 'error', 'warning', 'warn']);
24
26
 
25
27
  class Action {
26
28
  private actor: CodeceptJS.I;
@@ -38,6 +40,8 @@ class Action {
38
40
  private recorder?: PlaywrightRecorder;
39
41
  private recovery: RecoveryRunner;
40
42
  private mainDocumentStatus: number | undefined = undefined;
43
+ private networkRequests: NetworkCall[] = [];
44
+ private baseOrigin: string;
41
45
 
42
46
  constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder, recovery?: RecoveryRunner) {
43
47
  this.actor = actor;
@@ -46,6 +50,7 @@ class Action {
46
50
  this.playwrightHelper = container.helpers('Playwright');
47
51
  this.recorder = recorder;
48
52
  this.recovery = recovery || ((fn) => fn());
53
+ this.baseOrigin = URL.parse(this.config.playwright?.url || '')?.origin || '';
49
54
  }
50
55
 
51
56
  async saveScreenshot(): Promise<string | undefined> {
@@ -127,9 +132,7 @@ class Action {
127
132
  const logPath = join(statesDir, logFile);
128
133
  const formattedLogs = browserLogs.map((log: any) => {
129
134
  const logTimestamp = new Date().toISOString();
130
- const level = (log.type || log.level || 'LOG').toUpperCase();
131
- const message = log.text || log.message || String(log);
132
- return `[${logTimestamp}] ${level}: ${message}`;
135
+ return `[${logTimestamp}] ${log.type.toUpperCase()}: ${log.text}`;
133
136
  });
134
137
  fs.writeFileSync(logPath, `${formattedLogs.join('\n')}\n`, 'utf8');
135
138
 
@@ -157,12 +160,16 @@ class Action {
157
160
  ariaSnapshotFile = ariaFileName;
158
161
  }
159
162
 
163
+ const networkRequests = this.networkRequests;
164
+ this.networkRequests = [];
165
+
160
166
  const result = new ActionResult({
161
167
  html,
162
168
  title,
163
169
  httpStatus: await this.captureMainDocumentStatus(),
164
170
  url,
165
171
  browserLogs,
172
+ networkRequests,
166
173
  htmlFile,
167
174
  logFile,
168
175
  screenshotFile,
@@ -202,26 +209,53 @@ class Action {
202
209
  }
203
210
  }
204
211
 
205
- private captureMainDocumentResponse(): () => void {
212
+ private captureResponses(): () => void {
206
213
  const page = this.playwrightHelper.page;
207
214
  if (!page?.on || !page?.off) return () => {};
208
215
 
209
216
  this.mainDocumentStatus = undefined;
217
+ this.networkRequests = [];
210
218
 
211
219
  const handler = (response: any) => {
212
220
  const request = response.request();
213
- if (request.resourceType() !== 'document') return;
214
- if (response.frame() !== page.mainFrame()) return;
215
221
  const status = response.status();
216
222
  if (typeof status !== 'number') return;
217
223
  if (status <= 0) return;
218
- this.mainDocumentStatus = status;
224
+
225
+ if (request.resourceType() === 'document') {
226
+ if (response.frame() !== page.mainFrame()) return;
227
+ this.mainDocumentStatus = status;
228
+ return;
229
+ }
230
+
231
+ this.recordNetworkCall(request, status);
219
232
  };
220
233
 
221
234
  page.on('response', handler);
222
235
  return () => page.off('response', handler);
223
236
  }
224
237
 
238
+ private recordNetworkCall(request: any, status: number): void {
239
+ const resourceType = request.resourceType();
240
+ if (resourceType !== 'xhr' && resourceType !== 'fetch') return;
241
+
242
+ const url = URL.parse(request.url());
243
+ if (!url) return;
244
+ if (url.origin !== this.baseOrigin) return;
245
+
246
+ const call: NetworkCall = { method: request.method(), path: url.pathname, status };
247
+ if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status)) return;
248
+
249
+ if (this.networkRequests.length >= MAX_NETWORK_CALLS) {
250
+ if (status < 400) return;
251
+ const succeeded = this.networkRequests.findIndex((r) => r.status < 400);
252
+ if (succeeded === -1) return;
253
+ this.networkRequests.splice(succeeded, 1);
254
+ }
255
+
256
+ this.networkRequests.push(call);
257
+ }
258
+
225
259
  /**
226
260
  * Capture HTML snapshots of all iframes on the page
227
261
  */
@@ -267,13 +301,7 @@ class Action {
267
301
  try {
268
302
  const logs = await (this.actor as any).grabBrowserLogs();
269
303
 
270
- // Filter for important logs (info, error, warning)
271
- const importantLogs = logs.filter((log: any) => {
272
- const level = log.type || log.level;
273
- return ['info', 'error', 'warning', 'warn'].includes(level);
274
- });
275
-
276
- return importantLogs;
304
+ return logs.map(toBrowserLog).filter((log: any) => IMPORTANT_LOG_LEVELS.has(log.type));
277
305
  } catch (error) {
278
306
  debugLog('Failed to capture browser logs:', error);
279
307
  return [];
@@ -292,7 +320,7 @@ class Action {
292
320
  const stepListener = attachStepLogger(executedSteps, assertionSteps);
293
321
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
294
322
  this.playwrightGroupId = groupId;
295
- const detachMainDocumentResponse = this.captureMainDocumentResponse();
323
+ const detachResponses = this.captureResponses();
296
324
  const activeSpan = Observability.getSpan();
297
325
  const tracer = trace.getTracer('ai');
298
326
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
@@ -341,7 +369,7 @@ class Action {
341
369
  throw err;
342
370
  } finally {
343
371
  this.restorePageTimeout();
344
- detachMainDocumentResponse();
372
+ detachResponses();
345
373
  if (groupId) await this.recorder!.endAction();
346
374
  detachStepLogger(stepListener);
347
375
  if (stepSpan) {
@@ -429,6 +457,12 @@ async function captureHtml(page: any, frame: any, actor: any): Promise<string> {
429
457
  throw new Error('Playwright page is unavailable for HTML capture');
430
458
  }
431
459
 
460
+ function toBrowserLog(log: any): { type: string; text: string } {
461
+ const type = typeof log.type === 'function' ? log.type() : log.type || log.level || 'log';
462
+ const text = typeof log.text === 'function' ? log.text() : log.text || log.message || String(log);
463
+ return { type, text: text.replace(/\s+/g, ' ').trim() };
464
+ }
465
+
432
466
  async function captureTitle(page: any, actor: any): Promise<string> {
433
467
  if (page?.title) return page.title();
434
468
  if (actor?.grabTitle) return actor.grabTitle();
@@ -16,7 +16,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
16
16
  researcher: ctx.explorBot.agentResearcher(),
17
17
  navigator: ctx.explorBot.agentNavigator(),
18
18
  });
19
- const { see, context, visualClick, learnExperience } = agentTools;
19
+ const { see, context, visualClick } = agentTools;
20
20
 
21
21
  const tools: Record<string, any> = {
22
22
  navigate: tool({
@@ -124,7 +124,6 @@ export function WithWebMode<T extends Constructor>(Base: T) {
124
124
 
125
125
  ...codeceptTools,
126
126
  context,
127
- learnExperience,
128
127
  };
129
128
 
130
129
  if (see) tools.see = see;
package/src/ai/captain.ts CHANGED
@@ -21,7 +21,7 @@ import type { Navigator } from './navigator.ts';
21
21
  import type { Provider } from './provider.ts';
22
22
  import { Researcher } from './researcher.ts';
23
23
  import { TaskAgent } from './task-agent.ts';
24
- import { withdrawVisionTools } from './tools.ts';
24
+ import { createLearnExperienceTool, withdrawVisionTools } from './tools.ts';
25
25
 
26
26
  const MAX_STEPS = 15;
27
27
 
@@ -241,6 +241,14 @@ export class Captain extends CaptainBase implements Agent {
241
241
 
242
242
  private coreTools(task: Task, onDone: (summary: string) => void) {
243
243
  return {
244
+ learnExperience: createLearnExperienceTool({
245
+ getExperienceTracker: () => this.getExperienceTracker(),
246
+ getState: () => {
247
+ const state = this.explorBot.stateManager().getCurrentState();
248
+ if (!state) return null;
249
+ return ActionResult.fromState(state);
250
+ },
251
+ }),
244
252
  done: tool({
245
253
  description: 'Call when the user request is fulfilled.',
246
254
  inputSchema: z.object({
@@ -17,6 +17,8 @@ export function toolExecutionLabel(input: Record<string, any> | undefined): stri
17
17
  return input?.explanation || input?.assertion || input?.reason || input?.request || '';
18
18
  }
19
19
 
20
+ export const NARRATION_TOOL = 'commentary';
21
+
20
22
  const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
21
23
  const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
22
24
 
@@ -227,6 +229,7 @@ export class Conversation {
227
229
  if (!Array.isArray(message.content)) continue;
228
230
  for (const part of message.content) {
229
231
  if (part.type !== 'tool-result') continue;
232
+ if (part.toolName === NARRATION_TOOL) continue;
230
233
  executions.push(toToolExecution(part.toolName, toolCalls.get(part.toolCallId) || {}, part.output));
231
234
  }
232
235
  }
package/src/ai/driller.ts CHANGED
@@ -30,7 +30,7 @@ import type { Navigator } from './navigator.ts';
30
30
  import type { Provider } from './provider.ts';
31
31
  import { drillLocatorRule } from './rules.ts';
32
32
  import { TaskAgent, isInteractive } from './task-agent.ts';
33
- import { createCodeceptJSTools } from './tools.ts';
33
+ import { createCodeceptJSTools, createLearnExperienceTool } from './tools.ts';
34
34
 
35
35
  const debugLog = createDebug('explorbot:driller');
36
36
 
@@ -306,7 +306,11 @@ export class Driller extends TaskAgent implements Agent {
306
306
 
307
307
  let finished = false;
308
308
  const actionTools = this.createVerifiedActionTools(createCodeceptJSTools(this.toolDeps, test), component);
309
- const tools = { ...actionTools, ...this.createDrillFlowTools(originalState, test, interactive) };
309
+ const learnExperience = createLearnExperienceTool({
310
+ getExperienceTracker: () => this.getExperienceTracker(),
311
+ getState: () => ActionResult.fromState(this.stateManager.getCurrentState() || originalState),
312
+ });
313
+ const tools = { ...actionTools, learnExperience, ...this.createDrillFlowTools(originalState, test, interactive) };
310
314
 
311
315
  await loop(
312
316
  async ({ stop, iteration }) => {
@@ -29,6 +29,28 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
29
29
 
30
30
  const captured = requestStore.findCapturedRequest(method, path);
31
31
  if (captured) {
32
+ if (captured.status >= 400) {
33
+ const rejectedCapture = {
34
+ status: captured.status,
35
+ requestBody: captured.requestBody || 'no body',
36
+ };
37
+ if (opts.spec) {
38
+ try {
39
+ const definition = extractEndpointDefinition(opts.spec, path, opts.baseEndpoint);
40
+ return { source: 'spec', method, path, definition, rejectedCapture };
41
+ } catch {
42
+ return { source: 'captured', method, path, usable: false, rejectedRequestBody: captured.requestBody || 'no body', status: captured.status };
43
+ }
44
+ }
45
+ return {
46
+ source: 'captured',
47
+ method: captured.method,
48
+ path: captured.path,
49
+ status: captured.status,
50
+ usable: false,
51
+ rejectedRequestBody: captured.requestBody || 'no body',
52
+ };
53
+ }
32
54
  return {
33
55
  source: 'captured',
34
56
  method: captured.method,
@@ -87,6 +109,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
87
109
  success: false,
88
110
  status: reqResult.status,
89
111
  statusText: reqResult.statusText,
112
+ category: responseCategory(reqResult.status),
90
113
  errorPreview: reqResult.rawResponseBody.substring(0, 300),
91
114
  };
92
115
  }
@@ -149,6 +172,16 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
149
172
  return { tools, getResult, isFinished };
150
173
  }
151
174
 
175
+ function responseCategory(status: number): ResponseCategory {
176
+ if (status === 400 || status === 422) return 'validation';
177
+ if (status === 401 || status === 403) return 'authorization';
178
+ if (status === 404) return 'not_found';
179
+ if (status === 409) return 'conflict';
180
+ if (status === 408 || status === 425 || status === 429) return 'temporary';
181
+ if (status >= 500) return 'server';
182
+ return 'client';
183
+ }
184
+
152
185
  function extractKeyFields(body: any, result: Record<string, any> = {}, depth = 0): Record<string, any> {
153
186
  if (!body || typeof body !== 'object' || depth > 5) return result;
154
187
 
@@ -179,3 +212,5 @@ export interface FishermanResult {
179
212
  created: Array<{ type: string; id?: string | number; title?: string }>;
180
213
  failed: Array<{ type: string; reason: string }>;
181
214
  }
215
+
216
+ type ResponseCategory = 'validation' | 'authorization' | 'not_found' | 'conflict' | 'temporary' | 'server' | 'client';
@@ -196,7 +196,7 @@ export class Fisherman implements Agent {
196
196
 
197
197
  AVAILABLE TOOLS:
198
198
  ${toolNames.join(', ')}.
199
- Use tool names exactly as listed. Do not invent aliases, combined names, or names with channel markers such as "commentary".
199
+ Use tool names exactly as listed. Do not invent aliases or combined names.
200
200
  Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
201
201
 
202
202
  WORKFLOW:
@@ -208,7 +208,8 @@ export class Fisherman implements Agent {
208
208
  RULES:
209
209
  - Always call getEndpointSpec before your first request to an unfamiliar endpoint
210
210
  - Chain requests logically — create parent resources before children
211
- - If a request fails, try once more with adjusted data before reporting failure
211
+ - Use the response category and error text to decide what failed: validation requires corrected data, authorization requires valid access, not_found requires a valid path or parent, and conflict requires resolving the conflicting state
212
+ - Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
212
213
  - Use realistic but unique data for each item (vary names, titles)
213
214
 
214
215
  ${dataProtectionRules}