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.
Files changed (82) 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/driller.js +6 -2
  17. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  18. package/dist/src/ai/fisherman-tools.js +39 -0
  19. package/dist/src/ai/fisherman.js +2 -1
  20. package/dist/src/ai/navigator.d.ts +2 -1
  21. package/dist/src/ai/navigator.js +5 -9
  22. package/dist/src/ai/pilot.js +39 -22
  23. package/dist/src/ai/planner/subpages.js +2 -16
  24. package/dist/src/ai/planner.js +1 -1
  25. package/dist/src/ai/provider.js +16 -1
  26. package/dist/src/ai/researcher/cache.d.ts +8 -3
  27. package/dist/src/ai/researcher/cache.js +13 -8
  28. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  29. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  30. package/dist/src/ai/researcher.js +4 -3
  31. package/dist/src/ai/rules.js +1 -5
  32. package/dist/src/ai/tester.d.ts +1 -1
  33. package/dist/src/ai/tester.js +13 -22
  34. package/dist/src/ai/tools.d.ts +8 -5
  35. package/dist/src/ai/tools.js +79 -56
  36. package/dist/src/commands/init-command.js +13 -20
  37. package/dist/src/config.js +3 -1
  38. package/dist/src/experience-tracker.d.ts +2 -0
  39. package/dist/src/experience-tracker.js +12 -0
  40. package/dist/src/explorbot.js +1 -1
  41. package/dist/src/playwright-recorder.js +6 -12
  42. package/dist/src/test-plan.d.ts +8 -0
  43. package/dist/src/test-plan.js +11 -0
  44. package/dist/src/utils/html-diff.d.ts +5 -0
  45. package/dist/src/utils/html-diff.js +65 -6
  46. package/dist/src/utils/strings.d.ts +2 -0
  47. package/dist/src/utils/strings.js +32 -0
  48. package/dist/src/utils/url-matcher.d.ts +1 -0
  49. package/dist/src/utils/url-matcher.js +31 -2
  50. package/docs/basics/getting-started.md +33 -10
  51. package/docs/basics/providers.md +6 -4
  52. package/docs/contributing/npm-package.md +73 -4
  53. package/models.json +4 -4
  54. package/package.json +6 -2
  55. package/src/action-result.ts +61 -16
  56. package/src/action.ts +51 -17
  57. package/src/ai/captain/web-mode.ts +1 -2
  58. package/src/ai/captain.ts +9 -1
  59. package/src/ai/driller.ts +6 -2
  60. package/src/ai/fisherman-tools.ts +35 -0
  61. package/src/ai/fisherman.ts +2 -1
  62. package/src/ai/navigator.ts +6 -10
  63. package/src/ai/pilot.ts +41 -24
  64. package/src/ai/planner/subpages.ts +2 -13
  65. package/src/ai/planner.ts +1 -1
  66. package/src/ai/provider.ts +17 -1
  67. package/src/ai/researcher/cache.ts +17 -9
  68. package/src/ai/researcher/deep-analysis.ts +1 -1
  69. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  70. package/src/ai/researcher.ts +4 -3
  71. package/src/ai/rules.ts +1 -5
  72. package/src/ai/tester.ts +13 -22
  73. package/src/ai/tools.ts +84 -60
  74. package/src/commands/init-command.ts +14 -20
  75. package/src/config.ts +2 -1
  76. package/src/experience-tracker.ts +13 -0
  77. package/src/explorbot.ts +1 -1
  78. package/src/playwright-recorder.ts +6 -11
  79. package/src/test-plan.ts +18 -0
  80. package/src/utils/html-diff.ts +72 -7
  81. package/src/utils/strings.ts +36 -0
  82. 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 parts = await buildDiffParts(originalDocument, modifiedDocument);
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(originalDocument, modifiedDocument) {
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(`TEXT:${n.content}`);
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(`TEXT:${n.content}`);
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, '').toLowerCase();
115
- return normalize(expectedPath) === normalize(currentPath);
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('openai/gpt-oss-20b:nitro'),
47
- visionModel: openrouter('google/gemma-4-31b-it'),
48
- agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
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. `google/gemma-4-31b-it`) |
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).
@@ -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('google/gemma-4-31b-it:nitro'),
55
- agenticModel: openrouter('google/gemma-4-31b-it:nitro'),
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.4-nano'),
122
- visionModel: openai('gpt-5.4-nano'),
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` 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.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",
@@ -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;