explorbot 0.2.5 → 0.3.0
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/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 +2 -1
- package/dist/src/ai/navigator.d.ts +2 -1
- package/dist/src/ai/navigator.js +5 -9
- package/dist/src/ai/pilot.js +39 -22
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +16 -1
- 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 +13 -22
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +79 -56
- package/dist/src/commands/init-command.js +13 -20
- 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/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/navigator.ts +6 -10
- package/src/ai/pilot.ts +41 -24
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +17 -1
- 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 +13 -22
- package/src/ai/tools.ts +84 -60
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +2 -1
- 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
|
@@ -2,6 +2,10 @@ import { parse, serialize } from 'parse5';
|
|
|
2
2
|
import { TAILWIND_CLASS_PATTERNS, TRASH_HTML_CLASSES, minifyHtml } from "./html.js";
|
|
3
3
|
import { isDynamicId, isGenericClass } from "./xpath.js";
|
|
4
4
|
const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
|
|
5
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
6
|
+
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
7
|
+
const MESSAGE_MAX_LENGTH = 200;
|
|
8
|
+
const MESSAGE_LIMIT = 8;
|
|
5
9
|
/**
|
|
6
10
|
* Get text content from an element node.
|
|
7
11
|
*/
|
|
@@ -141,7 +145,9 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
|
|
|
141
145
|
const modifiedLines = flattenHtml(modifiedRoot);
|
|
142
146
|
const similarity = calculateSimilarity(originalLines, modifiedLines);
|
|
143
147
|
const { added, removed } = findDifferences(originalLines, modifiedLines);
|
|
144
|
-
const
|
|
148
|
+
const originalMap = collectElementMap(originalDocument);
|
|
149
|
+
const modifiedMap = collectElementMap(modifiedDocument);
|
|
150
|
+
const parts = await buildDiffParts(originalMap, modifiedMap);
|
|
145
151
|
const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
|
|
146
152
|
const allAdded = [...added, ...structuralAdditions];
|
|
147
153
|
const totalChanges = allAdded.length + removed.length;
|
|
@@ -152,8 +158,63 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
|
|
|
152
158
|
removed,
|
|
153
159
|
similarity,
|
|
154
160
|
summary,
|
|
161
|
+
messages: collectMessages(originalMap, modifiedMap, allAdded),
|
|
155
162
|
};
|
|
156
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
|
|
166
|
+
*/
|
|
167
|
+
function collectMessages(originalMap, modifiedMap, added) {
|
|
168
|
+
const appearedText = added.filter((line) => line.startsWith(TEXT_LINE_PREFIX)).map((line) => line.slice(TEXT_LINE_PREFIX.length));
|
|
169
|
+
return limitMessages([...collectLiveRegionTexts(originalMap, modifiedMap), ...appearedText]);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
|
|
173
|
+
*/
|
|
174
|
+
export function liveRegionMessages(originalHtml, modifiedHtml) {
|
|
175
|
+
const originalMap = collectElementMap(parseDocument(originalHtml));
|
|
176
|
+
const modifiedMap = collectElementMap(parseDocument(modifiedHtml));
|
|
177
|
+
return limitMessages(collectLiveRegionTexts(originalMap, modifiedMap));
|
|
178
|
+
}
|
|
179
|
+
function limitMessages(candidates) {
|
|
180
|
+
const messages = [];
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
const text = candidate.replace(/\s+/g, ' ').trim().slice(0, MESSAGE_MAX_LENGTH);
|
|
183
|
+
if (!text)
|
|
184
|
+
continue;
|
|
185
|
+
if (messages.some((message) => message.includes(text)))
|
|
186
|
+
continue;
|
|
187
|
+
messages.push(text);
|
|
188
|
+
if (messages.length === MESSAGE_LIMIT)
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
return messages;
|
|
192
|
+
}
|
|
193
|
+
function collectLiveRegionTexts(originalMap, modifiedMap) {
|
|
194
|
+
const texts = [];
|
|
195
|
+
for (const [path, element] of modifiedMap) {
|
|
196
|
+
if (!isLiveRegion(element))
|
|
197
|
+
continue;
|
|
198
|
+
const text = getTextContent(element).trim();
|
|
199
|
+
if (!text)
|
|
200
|
+
continue;
|
|
201
|
+
const previous = originalMap.get(path);
|
|
202
|
+
if (previous && getTextContent(previous).trim() === text)
|
|
203
|
+
continue;
|
|
204
|
+
texts.push(text);
|
|
205
|
+
}
|
|
206
|
+
return texts;
|
|
207
|
+
}
|
|
208
|
+
function isLiveRegion(element) {
|
|
209
|
+
if (element.tagName?.toLowerCase() === 'output')
|
|
210
|
+
return true;
|
|
211
|
+
const attrs = element.attrs ?? [];
|
|
212
|
+
const role = attrs.find((attr) => attr.name === 'role')?.value.toLowerCase();
|
|
213
|
+
if (role && LIVE_REGION_ROLES.has(role))
|
|
214
|
+
return true;
|
|
215
|
+
const live = attrs.find((attr) => attr.name === 'aria-live')?.value.toLowerCase();
|
|
216
|
+
return live === 'polite' || live === 'assertive';
|
|
217
|
+
}
|
|
157
218
|
/**
|
|
158
219
|
* Parse HTML into a document, wrapping fragments with html/body for consistency.
|
|
159
220
|
* Uses custom sanitization that removes iframes for diff purposes.
|
|
@@ -354,9 +415,7 @@ function findStableContainer(topLevelPath, originalMap, modifiedMap) {
|
|
|
354
415
|
}
|
|
355
416
|
return { path: 'html[1]/body[1]', selector: 'body' };
|
|
356
417
|
}
|
|
357
|
-
async function buildDiffParts(
|
|
358
|
-
const originalMap = collectElementMap(originalDocument);
|
|
359
|
-
const modifiedMap = collectElementMap(modifiedDocument);
|
|
418
|
+
async function buildDiffParts(originalMap, modifiedMap) {
|
|
360
419
|
const addedPaths = [];
|
|
361
420
|
const changedPaths = [];
|
|
362
421
|
for (const [path, element] of modifiedMap.entries()) {
|
|
@@ -624,7 +683,7 @@ function flattenHtml(node) {
|
|
|
624
683
|
function process(n) {
|
|
625
684
|
if (n.type === 'text' && n.content) {
|
|
626
685
|
if (n.content.length >= 5) {
|
|
627
|
-
lines.push(
|
|
686
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
628
687
|
}
|
|
629
688
|
return;
|
|
630
689
|
}
|
|
@@ -643,7 +702,7 @@ function flattenHtml(node) {
|
|
|
643
702
|
return;
|
|
644
703
|
}
|
|
645
704
|
if (n.content && n.content.length >= 5) {
|
|
646
|
-
lines.push(
|
|
705
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
647
706
|
}
|
|
648
707
|
if (n.children) {
|
|
649
708
|
n.children.forEach((child) => process(child));
|
|
@@ -3,3 +3,5 @@ export declare function slugify(text: string): string;
|
|
|
3
3
|
export declare function normalizeInlineText(text: string): string;
|
|
4
4
|
export declare function sanitizeFilename(name: string): string;
|
|
5
5
|
export declare function safeFilename(name: string, ext?: string, maxBytes?: number): string;
|
|
6
|
+
export declare function truncate(text: string, max: number): string;
|
|
7
|
+
export declare function compactErrorMessage(error: unknown): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import stripAnsi from 'strip-ansi';
|
|
2
3
|
export function truncateJson(input) {
|
|
3
4
|
if (!input)
|
|
4
5
|
return '';
|
|
@@ -36,3 +37,34 @@ export function safeFilename(name, ext = '', maxBytes = 240) {
|
|
|
36
37
|
}
|
|
37
38
|
return truncated + suffix + ext;
|
|
38
39
|
}
|
|
40
|
+
export function truncate(text, max) {
|
|
41
|
+
if (text.length <= max)
|
|
42
|
+
return text;
|
|
43
|
+
return `${text.slice(0, max - 3)}...`;
|
|
44
|
+
}
|
|
45
|
+
const MAX_COMPACT_ERROR = 400;
|
|
46
|
+
export function compactErrorMessage(error) {
|
|
47
|
+
let text = stripAnsi(String(error));
|
|
48
|
+
for (const strip of STRIP_STRATEGIES) {
|
|
49
|
+
text = strip(text);
|
|
50
|
+
}
|
|
51
|
+
return truncate(text, MAX_COMPACT_ERROR);
|
|
52
|
+
}
|
|
53
|
+
function stripCallLog(text) {
|
|
54
|
+
const CALL_LOG = 'Call log:';
|
|
55
|
+
const NOISE = ['attempting', 'retrying', 'waiting'];
|
|
56
|
+
const [headline, ...log] = text.split(CALL_LOG);
|
|
57
|
+
if (!log.length)
|
|
58
|
+
return text;
|
|
59
|
+
const lines = new Set();
|
|
60
|
+
for (const line of log.join(CALL_LOG).split('\n')) {
|
|
61
|
+
const cleaned = normalizeInlineText(line);
|
|
62
|
+
if (!cleaned)
|
|
63
|
+
continue;
|
|
64
|
+
if (NOISE.some((noise) => cleaned.includes(noise)))
|
|
65
|
+
continue;
|
|
66
|
+
lines.add(cleaned);
|
|
67
|
+
}
|
|
68
|
+
return [headline.trim(), ...lines].join(' ');
|
|
69
|
+
}
|
|
70
|
+
const STRIP_STRATEGIES = [stripCallLog];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function isDynamicSegment(segment: string): boolean;
|
|
2
2
|
export declare function hasDynamicUrlSegment(url: string): boolean;
|
|
3
|
+
export declare function isSamePageFamily(urlA: string, urlB: string): boolean;
|
|
3
4
|
export declare function generalizeSegment(segment: string): string;
|
|
4
5
|
export declare function generalizeUrl(url: string): string;
|
|
5
6
|
export declare function matchesUrl(pattern: string, path: string): boolean;
|
|
@@ -32,6 +32,23 @@ export function isDynamicSegment(segment) {
|
|
|
32
32
|
export function hasDynamicUrlSegment(url) {
|
|
33
33
|
return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
|
|
34
34
|
}
|
|
35
|
+
export function isSamePageFamily(urlA, urlB) {
|
|
36
|
+
const partsA = new URL(urlA, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
37
|
+
const partsB = new URL(urlB, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
38
|
+
if (partsA.length !== partsB.length)
|
|
39
|
+
return false;
|
|
40
|
+
let diffCount = 0;
|
|
41
|
+
for (let i = 0; i < partsA.length; i++) {
|
|
42
|
+
if (partsA[i] === partsB[i])
|
|
43
|
+
continue;
|
|
44
|
+
diffCount++;
|
|
45
|
+
if (diffCount > 1)
|
|
46
|
+
return false;
|
|
47
|
+
if (!isDynamicSegment(partsA[i]) || !isDynamicSegment(partsB[i]))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
35
52
|
export function generalizeSegment(segment) {
|
|
36
53
|
if (/^\d+$/.test(segment))
|
|
37
54
|
return '\\d+';
|
|
@@ -111,6 +128,18 @@ export function matchesNavigationUrl(expected, current) {
|
|
|
111
128
|
if (!expectedPath.includes('?')) {
|
|
112
129
|
currentPath = currentPath.split('?')[0];
|
|
113
130
|
}
|
|
114
|
-
const normalize = (value) => value.replace(/^\/+|\/+$/g, '')
|
|
115
|
-
|
|
131
|
+
const normalize = (value) => value.replace(/^\/+|\/+$/g, '');
|
|
132
|
+
const expectedNormalized = normalize(expectedPath);
|
|
133
|
+
const currentNormalized = normalize(currentPath);
|
|
134
|
+
const expectedKey = expectedNormalized.toLowerCase();
|
|
135
|
+
const currentKey = currentNormalized.toLowerCase();
|
|
136
|
+
if (expectedKey === currentKey)
|
|
137
|
+
return true;
|
|
138
|
+
if (!currentKey.startsWith(`${expectedKey}/`))
|
|
139
|
+
return false;
|
|
140
|
+
const recordSegments = currentNormalized
|
|
141
|
+
.slice(expectedKey.length + 1)
|
|
142
|
+
.split('/')
|
|
143
|
+
.filter(Boolean);
|
|
144
|
+
return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
|
|
116
145
|
}
|
|
@@ -32,30 +32,53 @@ OPENROUTER_API_KEY=sk-...
|
|
|
32
32
|
Then open `explorbot.config.js` and set your app's base URL — the host only, no path:
|
|
33
33
|
|
|
34
34
|
```javascript
|
|
35
|
-
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
36
|
-
|
|
37
|
-
const openrouter = createOpenRouter({
|
|
38
|
-
apiKey: process.env.OPENROUTER_API_KEY,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
35
|
export default {
|
|
42
36
|
web: {
|
|
43
37
|
url: 'http://localhost:3000',
|
|
44
38
|
},
|
|
45
39
|
ai: {
|
|
46
|
-
model: openrouter
|
|
47
|
-
visionModel: openrouter
|
|
48
|
-
agenticModel: openrouter
|
|
40
|
+
model: 'openrouter/openai/gpt-oss-20b:nitro',
|
|
41
|
+
visionModel: 'openrouter/openai/gpt-5.6-luna',
|
|
42
|
+
agenticModel: 'openrouter/openai/gpt-5.6-luna',
|
|
49
43
|
},
|
|
50
44
|
};
|
|
51
45
|
```
|
|
52
46
|
|
|
47
|
+
That shorthand — `'provider/model-id'` — uses a provider package Explorbot ships, so nothing extra is installed. Bundled providers:
|
|
48
|
+
|
|
49
|
+
- `openai`
|
|
50
|
+
- `anthropic`
|
|
51
|
+
- `google`
|
|
52
|
+
- `groq`
|
|
53
|
+
- `mistral`
|
|
54
|
+
- `openrouter`
|
|
55
|
+
- `sambanova`
|
|
56
|
+
|
|
57
|
+
The other style is explicit: install a Vercel AI SDK package (`npm i @ai-sdk/openai`) and build the client yourself. It works for the providers above too, and it is the only way to reach one that isn't bundled, a custom `baseURL`, or extra client options:
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
61
|
+
|
|
62
|
+
const poolside = createOpenAI({
|
|
63
|
+
apiKey: process.env.POOLSIDE_API_KEY,
|
|
64
|
+
baseURL: 'https://inference.poolside.ai/v1',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
ai: {
|
|
69
|
+
model: poolside('poolside/laguna-xs-2.1'),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Both styles mix freely across the three keys. See [Providers](./providers.md).
|
|
75
|
+
|
|
53
76
|
Explorbot uses three models. Pick each one for speed and cost:
|
|
54
77
|
|
|
55
78
|
| Model | Config key | Used by | Pick |
|
|
56
79
|
|-------|-----------|---------|------|
|
|
57
80
|
| `model` | `ai.model` | Tester, Navigator, Researcher — they read HTML and ARIA on every step | a fast, cheap model (e.g. `openai/gpt-oss-20b:nitro`) |
|
|
58
|
-
| `visionModel` | `ai.visionModel` | screenshot analysis | a vision model (e.g. `
|
|
81
|
+
| `visionModel` | `ai.visionModel` | screenshot analysis | a vision model (e.g. `openai/gpt-5.6-luna`) |
|
|
59
82
|
| `agenticModel` | `ai.agenticModel` | Captain and Pilot — they read short action logs and make the big decisions | a smarter model (e.g. MiniMax 2.5, Grok Fast) |
|
|
60
83
|
|
|
61
84
|
Captain and Pilot barely use tokens, so a smarter `agenticModel` improves results for almost no extra cost. OpenRouter is the simplest start — one key, many models. To use OpenAI, Anthropic, Groq, or others, see [Providers](./providers.md). For every config option, see [Configuration](../reference/configuration.md).
|
package/docs/basics/providers.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Explorbot connects to AI providers through the [Vercel AI SDK](https://sdk.vercel.ai/). Use any supported provider, and mix providers across different models.
|
|
4
4
|
|
|
5
|
+
Every provider below is set up the classical way: install its package, import it, build the client. Explorbot bundles some of these packages — for those you can skip the install and name the model as `'provider/model-id'` instead. See [Getting Started](./getting-started.md#2-configure) for that list and the two styles side by side.
|
|
6
|
+
|
|
5
7
|
> The `export default` config block inside each `<!-- START/END provider -->` marker is generated from [`models.json`](../../models.json). After editing that file, run `bunosh docs:sync`. Everything else — including the import blocks — is hand-written.
|
|
6
8
|
|
|
7
9
|
## Requirements
|
|
@@ -51,8 +53,8 @@ Set the recommended models in the exported config:
|
|
|
51
53
|
export default {
|
|
52
54
|
ai: {
|
|
53
55
|
model: openrouter('openai/gpt-oss-20b:nitro'),
|
|
54
|
-
visionModel: openrouter('
|
|
55
|
-
agenticModel: openrouter('
|
|
56
|
+
visionModel: openrouter('openai/gpt-5.6-luna'),
|
|
57
|
+
agenticModel: openrouter('openai/gpt-5.6-luna'),
|
|
56
58
|
},
|
|
57
59
|
};
|
|
58
60
|
```
|
|
@@ -118,8 +120,8 @@ Set the recommended models in the exported config:
|
|
|
118
120
|
```javascript
|
|
119
121
|
export default {
|
|
120
122
|
ai: {
|
|
121
|
-
model: openai('gpt-5
|
|
122
|
-
visionModel: openai('gpt-5.
|
|
123
|
+
model: openai('gpt-5-nano'),
|
|
124
|
+
visionModel: openai('gpt-5.6-luna'),
|
|
123
125
|
agenticModel: openai('gpt-5.6-luna'),
|
|
124
126
|
},
|
|
125
127
|
};
|
|
@@ -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.0",
|
|
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;
|