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.
- package/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +17 -4
- package/dist/boat/prima/src/cli.js +7 -0
- package/dist/boat/prima/src/prima.js +18 -4
- package/dist/models.json +4 -4
- package/dist/package.json +6 -2
- package/dist/src/action-result.d.ts +13 -0
- package/dist/src/action-result.js +46 -15
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +48 -17
- package/dist/src/ai/captain/web-mode.js +1 -2
- package/dist/src/ai/captain.d.ts +20 -0
- package/dist/src/ai/captain.js +10 -1
- package/dist/src/ai/conversation.d.ts +1 -0
- package/dist/src/ai/conversation.js +3 -0
- package/dist/src/ai/driller.js +6 -2
- package/dist/src/ai/fisherman-tools.d.ts +40 -1
- package/dist/src/ai/fisherman-tools.js +39 -0
- package/dist/src/ai/fisherman.js +3 -2
- package/dist/src/ai/navigator.d.ts +2 -1
- package/dist/src/ai/navigator.js +5 -9
- package/dist/src/ai/pilot.js +51 -29
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.d.ts +3 -0
- package/dist/src/ai/provider.js +80 -17
- package/dist/src/ai/researcher/cache.d.ts +8 -3
- package/dist/src/ai/researcher/cache.js +13 -8
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
- package/dist/src/ai/researcher.js +4 -3
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +34 -26
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +79 -56
- package/dist/src/commands/explore-command.js +22 -17
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.d.ts +2 -1
- package/dist/src/config.js +3 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +1 -1
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/test-plan.d.ts +8 -0
- package/dist/src/test-plan.js +11 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +32 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +31 -2
- package/docs/basics/getting-started.md +33 -10
- package/docs/basics/providers.md +6 -4
- package/docs/contributing/npm-package.md +73 -4
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +51 -17
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/conversation.ts +3 -0
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +3 -2
- package/src/ai/navigator.ts +6 -10
- package/src/ai/pilot.ts +54 -32
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +111 -41
- package/src/ai/researcher/cache.ts +17 -9
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +1 -5
- package/src/ai/tester.ts +32 -27
- package/src/ai/tools.ts +84 -60
- package/src/commands/explore-command.ts +17 -14
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +4 -2
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +1 -1
- package/src/playwright-recorder.ts +6 -11
- package/src/test-plan.ts +18 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/strings.ts +36 -0
- 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`
|
|
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
|
|
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
|
|
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": "
|
|
5
|
-
"agenticModel": "
|
|
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
|
|
17
|
-
"visionModel": "gpt-5.
|
|
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.
|
|
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",
|
package/src/action-result.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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({
|
package/src/ai/conversation.ts
CHANGED
|
@@ -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
|
|
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';
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
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}
|