explorbot 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/conversation.d.ts +1 -0
  17. package/dist/src/ai/conversation.js +3 -0
  18. package/dist/src/ai/driller.js +6 -2
  19. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  20. package/dist/src/ai/fisherman-tools.js +39 -0
  21. package/dist/src/ai/fisherman.js +3 -2
  22. package/dist/src/ai/navigator.d.ts +2 -1
  23. package/dist/src/ai/navigator.js +5 -9
  24. package/dist/src/ai/pilot.js +51 -29
  25. package/dist/src/ai/planner/subpages.js +2 -16
  26. package/dist/src/ai/planner.js +1 -1
  27. package/dist/src/ai/provider.d.ts +3 -0
  28. package/dist/src/ai/provider.js +80 -17
  29. package/dist/src/ai/researcher/cache.d.ts +8 -3
  30. package/dist/src/ai/researcher/cache.js +13 -8
  31. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  32. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  33. package/dist/src/ai/researcher.js +4 -3
  34. package/dist/src/ai/rules.js +1 -5
  35. package/dist/src/ai/tester.d.ts +1 -1
  36. package/dist/src/ai/tester.js +34 -26
  37. package/dist/src/ai/tools.d.ts +8 -5
  38. package/dist/src/ai/tools.js +79 -56
  39. package/dist/src/commands/explore-command.js +22 -17
  40. package/dist/src/commands/init-command.js +13 -20
  41. package/dist/src/config.d.ts +2 -1
  42. package/dist/src/config.js +3 -1
  43. package/dist/src/experience-tracker.d.ts +2 -0
  44. package/dist/src/experience-tracker.js +12 -0
  45. package/dist/src/explorbot.js +1 -1
  46. package/dist/src/playwright-recorder.js +6 -12
  47. package/dist/src/test-plan.d.ts +8 -0
  48. package/dist/src/test-plan.js +11 -0
  49. package/dist/src/utils/html-diff.d.ts +5 -0
  50. package/dist/src/utils/html-diff.js +65 -6
  51. package/dist/src/utils/strings.d.ts +2 -0
  52. package/dist/src/utils/strings.js +32 -0
  53. package/dist/src/utils/url-matcher.d.ts +1 -0
  54. package/dist/src/utils/url-matcher.js +31 -2
  55. package/docs/basics/getting-started.md +33 -10
  56. package/docs/basics/providers.md +6 -4
  57. package/docs/contributing/npm-package.md +73 -4
  58. package/models.json +4 -4
  59. package/package.json +6 -2
  60. package/src/action-result.ts +61 -16
  61. package/src/action.ts +51 -17
  62. package/src/ai/captain/web-mode.ts +1 -2
  63. package/src/ai/captain.ts +9 -1
  64. package/src/ai/conversation.ts +3 -0
  65. package/src/ai/driller.ts +6 -2
  66. package/src/ai/fisherman-tools.ts +35 -0
  67. package/src/ai/fisherman.ts +3 -2
  68. package/src/ai/navigator.ts +6 -10
  69. package/src/ai/pilot.ts +54 -32
  70. package/src/ai/planner/subpages.ts +2 -13
  71. package/src/ai/planner.ts +1 -1
  72. package/src/ai/provider.ts +111 -41
  73. package/src/ai/researcher/cache.ts +17 -9
  74. package/src/ai/researcher/deep-analysis.ts +1 -1
  75. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  76. package/src/ai/researcher.ts +4 -3
  77. package/src/ai/rules.ts +1 -5
  78. package/src/ai/tester.ts +32 -27
  79. package/src/ai/tools.ts +84 -60
  80. package/src/commands/explore-command.ts +17 -14
  81. package/src/commands/init-command.ts +14 -20
  82. package/src/config.ts +4 -2
  83. package/src/experience-tracker.ts +13 -0
  84. package/src/explorbot.ts +1 -1
  85. package/src/playwright-recorder.ts +6 -11
  86. package/src/test-plan.ts +18 -0
  87. package/src/utils/html-diff.ts +72 -7
  88. package/src/utils/strings.ts +36 -0
  89. package/src/utils/url-matcher.ts +27 -2
@@ -7,15 +7,10 @@ import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "..
7
7
  import { getCliName } from "../utils/cli-name.js";
8
8
  import { log, tag } from '../utils/logger.js';
9
9
  import { relativeToCwd } from "../utils/next-steps.js";
10
- const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
11
- // import { '<your provider here>' } from '<your provider package here>';
12
-
13
- // Vercel AI SDK is used to connect to AI providers.
14
- // Bring your own provider or use OpenRouter (one API key, many providers).
15
- // https://github.com/testomatio/explorbot/blob/main/docs/providers.md
16
- const openrouter = createOpenRouter({
17
- apiKey: process.env.OPENROUTER_API_KEY,
18
- });
10
+ function defaultConfigTemplate() {
11
+ return `// 'provider/model-id' uses a bundled provider.
12
+ // It is also possible to import provider as a module from Vercel AI SDK.
13
+ // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
19
14
 
20
15
  const config = {
21
16
  web: {
@@ -24,12 +19,7 @@ const config = {
24
19
  },
25
20
 
26
21
  ai: {
27
- // fast model with tool calling capabilities
28
- model: openrouter('openai/gpt-oss-20b:nitro'),
29
- // vision model for screenshot analysis
30
- visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
31
- // agentic model for decision making
32
- agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
22
+ ${modelLines('openrouter')}
33
23
  },
34
24
 
35
25
  reporter: {
@@ -44,6 +34,7 @@ const config = {
44
34
 
45
35
  export default config;
46
36
  `;
37
+ }
47
38
  const DEFAULT_ENV_TEMPLATE = dedent `
48
39
  # AI provider API keys
49
40
  OPENROUTER_API_KEY=
@@ -127,7 +118,7 @@ export function runInitCommand(options) {
127
118
  log('Use --force to overwrite existing file');
128
119
  process.exit(1);
129
120
  }
130
- writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
121
+ writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
131
122
  log(`Created config file: ${relativeToCwd(outPath)}`);
132
123
  const envPath = resolve(process.cwd(), '.env');
133
124
  if (!existsSync(envPath)) {
@@ -196,15 +187,17 @@ async function renderInitWizard(mode) {
196
187
  }), { exitOnCtrlC: false, patchConsole: false });
197
188
  });
198
189
  }
199
- function globalConfigTemplate(provider) {
200
- const { envKey } = PROVIDERS[provider];
190
+ function modelLines(provider) {
201
191
  const recommended = ConfigParser.recommendedModels()[provider] || {};
202
192
  const roles = [
203
193
  ['model', 'fast model with tool calling capabilities'],
204
194
  ['visionModel', 'vision model for screenshot analysis'],
205
195
  ['agenticModel', 'agentic model for decision making'],
206
196
  ];
207
- const models = roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
197
+ return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
198
+ }
199
+ function globalConfigTemplate(provider) {
200
+ const { envKey } = PROVIDERS[provider];
208
201
  return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
209
202
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
210
203
  // The key is read from ${envKey} in ~/.explorbot/.env
@@ -212,7 +205,7 @@ function globalConfigTemplate(provider) {
212
205
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
213
206
  const config = {
214
207
  ai: {
215
- ${models}
208
+ ${modelLines(provider)}
216
209
  },
217
210
 
218
211
  reporter: {
@@ -151,8 +151,9 @@ interface AIConfig {
151
151
  vision?: boolean;
152
152
  visionModel?: any;
153
153
  agenticModel?: any;
154
- maxAttempts?: number;
154
+ retryAttempts?: number;
155
155
  retryDelay?: number;
156
+ maxParallelRequests?: number;
156
157
  agents?: AgentsConfig;
157
158
  }
158
159
  interface HtmlConfig {
@@ -157,7 +157,7 @@ export class ConfigParser {
157
157
  const config = this.getConfig();
158
158
  if (!this.configPath)
159
159
  throw new Error('Config path not found');
160
- return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
160
+ return this.resolveProjectDir(config.dirs?.output || 'output');
161
161
  }
162
162
  getProjectRoot() {
163
163
  if (this.site)
@@ -168,6 +168,8 @@ export class ConfigParser {
168
168
  return process.cwd();
169
169
  }
170
170
  resolveProjectDir(relativeDir) {
171
+ if (path.isAbsolute(relativeDir))
172
+ return relativeDir;
171
173
  if (!this.configPath)
172
174
  return relativeDir;
173
175
  return path.join(this.getProjectRoot(), relativeDir);
@@ -53,6 +53,7 @@ export declare class ExperienceTracker {
53
53
  getExperienceTableOfContents(state: ActionResult, options?: {
54
54
  includeDescendantExperience?: boolean;
55
55
  }): ExperienceTocEntry[];
56
+ renderExperienceFor(state: ActionResult): string;
56
57
  renderExperienceTocFor(state: ActionResult): string;
57
58
  getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: {
58
59
  includeDescendantExperience?: boolean;
@@ -77,6 +78,7 @@ export declare class ExperienceTracker {
77
78
  fileHash: string;
78
79
  } | null;
79
80
  }
81
+ export declare function renderExperienceRecipes(recipes: string[]): string;
80
82
  export declare function renderExperienceToc(toc: ExperienceTocEntry[]): string;
81
83
  export interface ExperienceFile {
82
84
  filePath: string;
@@ -257,6 +257,13 @@ export class ExperienceTracker {
257
257
  });
258
258
  return this.buildToc(sorted);
259
259
  }
260
+ renderExperienceFor(state) {
261
+ const successful = this.getSuccessfulExperience(state);
262
+ if (!successful.length)
263
+ return '';
264
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
265
+ return renderExperienceRecipes(successful);
266
+ }
260
267
  renderExperienceTocFor(state) {
261
268
  const toc = this.getExperienceTableOfContents(state);
262
269
  if (toc.length === 0)
@@ -381,6 +388,11 @@ function indexToLetters(index) {
381
388
  }
382
389
  return result;
383
390
  }
391
+ export function renderExperienceRecipes(recipes) {
392
+ if (recipes.length === 0)
393
+ return '';
394
+ return `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${recipes.join('\n\n')}\n</experience>`;
395
+ }
384
396
  export function renderExperienceToc(toc) {
385
397
  if (toc.length === 0)
386
398
  return '';
@@ -199,7 +199,7 @@ export class ExplorBot {
199
199
  this.agents.tester = this.createAgent((deps) => {
200
200
  const researcher = this.agentResearcher();
201
201
  const navigator = this.agentNavigator();
202
- const tools = createAgentTools({ ...deps, researcher, navigator });
202
+ const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
203
203
  return new Tester(deps, researcher, navigator, tools);
204
204
  });
205
205
  const qm = this.agentQuartermaster();
@@ -6,7 +6,7 @@ const RECORDABLE = {
6
6
  Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
7
7
  Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
8
8
  };
9
- const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
9
+ const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
10
10
  let cachedAsLocator = null;
11
11
  let asLocatorLoadAttempted = false;
12
12
  const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url);
@@ -16,17 +16,11 @@ function getAsLocator() {
16
16
  if (asLocatorLoadAttempted)
17
17
  throw new Error(PLAYWRIGHT_INCOMPATIBLE);
18
18
  asLocatorLoadAttempted = true;
19
- try {
20
- const mod = nodeRequire('playwright-core/lib/utils');
21
- if (typeof mod?.asLocator === 'function') {
22
- cachedAsLocator = mod.asLocator;
23
- return cachedAsLocator;
24
- }
25
- }
26
- catch {
27
- // Module not exported or not found
28
- }
29
- throw new Error(PLAYWRIGHT_INCOMPATIBLE);
19
+ const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
20
+ if (typeof asLocator !== 'function')
21
+ throw new Error(PLAYWRIGHT_INCOMPATIBLE);
22
+ cachedAsLocator = asLocator;
23
+ return cachedAsLocator;
30
24
  }
31
25
  export class PlaywrightRecorder {
32
26
  context = null;
@@ -1,3 +1,4 @@
1
+ import type { ActionResult } from './action-result.js';
1
2
  import { WebPageState } from './state-manager.js';
2
3
  export declare const TestResult: {
3
4
  readonly PASSED: "passed";
@@ -94,10 +95,13 @@ export declare class Test extends Task {
94
95
  startTime?: number;
95
96
  endTime?: number;
96
97
  resetCount: number;
98
+ appliedExperience: AppliedExperience[];
97
99
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps?: string[]);
98
100
  getVisitedUrls({ localOnly }?: {
99
101
  localOnly?: boolean;
100
102
  }): string[];
103
+ applyExperience(recipes: AppliedExperience[]): void;
104
+ getAppliedExperience(state: ActionResult): string[];
101
105
  addArtifact(artifact?: string): void;
102
106
  get hasFinished(): boolean;
103
107
  get isSuccessful(): boolean;
@@ -166,4 +170,8 @@ interface UrlNoteState {
166
170
  h2?: string;
167
171
  screenshotFile?: string;
168
172
  }
173
+ interface AppliedExperience {
174
+ url: string;
175
+ content: string;
176
+ }
169
177
  export {};
@@ -190,6 +190,7 @@ export class Test extends Task {
190
190
  startTime;
191
191
  endTime;
192
192
  resetCount = 0;
193
+ appliedExperience = [];
193
194
  constructor(scenario, priority, expectedOutcome, startUrl, plannedSteps = []) {
194
195
  super(scenario, startUrl);
195
196
  this.scenario = scenario;
@@ -208,6 +209,16 @@ export class Test extends Task {
208
209
  }
209
210
  return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value) => Boolean(value) && value.trim() !== ''))];
210
211
  }
212
+ applyExperience(recipes) {
213
+ for (const recipe of recipes) {
214
+ if (this.appliedExperience.some((applied) => applied.content === recipe.content))
215
+ continue;
216
+ this.appliedExperience.push(recipe);
217
+ }
218
+ }
219
+ getAppliedExperience(state) {
220
+ return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
221
+ }
211
222
  addArtifact(artifact) {
212
223
  if (!artifact)
213
224
  return;
@@ -11,9 +11,14 @@ export interface HtmlDiffResult {
11
11
  removed: string[];
12
12
  similarity: number;
13
13
  summary: string;
14
+ messages: string[];
14
15
  }
15
16
  export declare function computeHtmlFingerprint(html: string): string[];
16
17
  /**
17
18
  * Compares two HTML documents and returns differences along with a diff subtree.
18
19
  */
19
20
  export declare function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult>;
21
+ /**
22
+ * Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
23
+ */
24
+ export declare function liveRegionMessages(originalHtml: string, modifiedHtml: string): string[];
@@ -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
  };