explorbot 0.4.8 → 0.4.10

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 (55) hide show
  1. package/boat/api-tester/src/cli.ts +1 -1
  2. package/boat/api-tester/src/config.ts +34 -26
  3. package/dist/boat/api-tester/src/cli.js +1 -1
  4. package/dist/boat/api-tester/src/config.js +32 -26
  5. package/dist/package.json +1 -1
  6. package/dist/src/action.js +4 -2
  7. package/dist/src/ai/fisherman/tools.js +10 -4
  8. package/dist/src/ai/navigator.d.ts +0 -1
  9. package/dist/src/ai/navigator.js +11 -24
  10. package/dist/src/ai/pilot.js +21 -10
  11. package/dist/src/ai/rerunner.js +7 -0
  12. package/dist/src/ai/tools.js +8 -1
  13. package/dist/src/api/xhr-capture.js +2 -1
  14. package/dist/src/commands/config-command.d.ts +2 -0
  15. package/dist/src/commands/config-command.js +8 -2
  16. package/dist/src/commands/init-command.js +6 -26
  17. package/dist/src/commands/sites-command.js +6 -1
  18. package/dist/src/config.d.ts +5 -4
  19. package/dist/src/config.js +25 -23
  20. package/dist/src/explorer.js +2 -3
  21. package/dist/src/global-config.d.ts +6 -0
  22. package/dist/src/global-config.js +82 -7
  23. package/dist/src/reporter.js +8 -4
  24. package/dist/src/utils/html.js +6 -0
  25. package/dist/src/utils/logger.js +1 -1
  26. package/dist/src/utils/merge.d.ts +1 -0
  27. package/dist/src/utils/merge.js +11 -0
  28. package/dist/src/utils/step-analyzer.d.ts +3 -0
  29. package/dist/src/utils/step-analyzer.js +7 -0
  30. package/dist/src/utils/url-matcher.d.ts +1 -0
  31. package/dist/src/utils/url-matcher.js +7 -0
  32. package/docs/reference/commands.md +10 -2
  33. package/docs/reference/configuration.md +37 -4
  34. package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
  35. package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
  36. package/package.json +1 -1
  37. package/src/action.ts +4 -2
  38. package/src/ai/fisherman/tools.ts +10 -4
  39. package/src/ai/navigator.ts +9 -23
  40. package/src/ai/pilot.ts +21 -10
  41. package/src/ai/rerunner.ts +4 -0
  42. package/src/ai/tools.ts +7 -1
  43. package/src/api/xhr-capture.ts +2 -1
  44. package/src/commands/config-command.ts +9 -2
  45. package/src/commands/init-command.ts +6 -27
  46. package/src/commands/sites-command.ts +6 -1
  47. package/src/config.ts +28 -25
  48. package/src/explorer.ts +2 -2
  49. package/src/global-config.ts +81 -6
  50. package/src/reporter.ts +8 -4
  51. package/src/utils/html.ts +6 -0
  52. package/src/utils/logger.ts +1 -1
  53. package/src/utils/merge.ts +13 -0
  54. package/src/utils/step-analyzer.ts +8 -0
  55. package/src/utils/url-matcher.ts +7 -0
@@ -1,4 +1,4 @@
1
- import { listSites, sitesDir } from '../global-config.js';
1
+ import { findSiteConfig, listSites, sitesDir } from '../global-config.js';
2
2
  import { getCliName } from '../utils/cli-name.js';
3
3
  import { tag } from '../utils/logger.js';
4
4
  import { BaseCommand } from './base-command.js';
@@ -16,6 +16,11 @@ export class SitesCommand extends BaseCommand {
16
16
  tag('info').log(`Registered sites (${sites.length}):`);
17
17
  for (const site of sites) {
18
18
  tag('info').log(` ${site.folder.padEnd(width)} ${site.url} last run ${site.lastRunAt.slice(0, 16).replace('T', ' ')}`);
19
+ let config = 'inherits global config';
20
+ const sitePath = findSiteConfig(site.dir);
21
+ if (sitePath)
22
+ config = sitePath;
23
+ tag('info').log(` ${' '.repeat(width)} ${config}`);
19
24
  }
20
25
  tag('info').log('');
21
26
  tag('info').log(`Stored in ${sitesDir()}`);
@@ -1,4 +1,4 @@
1
- import { type SiteRecord } from './global-config.js';
1
+ import { EXPLORBOT_CONFIG_PATHS, type SiteRecord } from './global-config.js';
2
2
  export declare const PROVIDERS: Record<string, ProviderInfo>;
3
3
  export declare const MODEL_ROLES: ModelRole[];
4
4
  interface PlaywrightConfig {
@@ -224,7 +224,7 @@ interface ExplorbotConfig {
224
224
  dynamicPageRegex?: string;
225
225
  }
226
226
  type RuleEntry = string | Record<string, string>;
227
- export declare const EXPLORBOT_CONFIG_PATHS: string[];
227
+ export { EXPLORBOT_CONFIG_PATHS };
228
228
  export declare const EXPLORBOT_ENV_VARS: EnvVar[];
229
229
  export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, ScoutAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
230
230
  export declare class ConfigParser {
@@ -235,6 +235,7 @@ export declare class ConfigParser {
235
235
  runtimeTarget: string | null;
236
236
  site: SiteRecord | null;
237
237
  siteStartPath: string;
238
+ siteConfigPath: string | null;
238
239
  constructor();
239
240
  static loadEnv(filePath: string, keepExisting?: boolean): void;
240
241
  static recommendedModels(): Record<string, Record<string, string>>;
@@ -252,6 +253,7 @@ export declare class ConfigParser {
252
253
  resolveProjectDir(relativeDir: string): string;
253
254
  isGlobalMode(): boolean;
254
255
  getSite(): SiteRecord | null;
256
+ getSiteConfigPath(): string | null;
255
257
  resolveTargetPath(target?: string): string;
256
258
  getStatesDir(): string;
257
259
  getPlansDir(): string;
@@ -260,7 +262,7 @@ export declare class ConfigParser {
260
262
  static setupTestConfig(): void;
261
263
  static getTestDirectories(): string[];
262
264
  static cleanupAllTestDirectories(): void;
263
- enterGlobalMode(config: ExplorbotConfig, target: string | null): void;
265
+ enterGlobalMode(config: ExplorbotConfig, target: string | null): Promise<ExplorbotConfig>;
264
266
  applyEnvSpec(config: ExplorbotConfig): void;
265
267
  buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
266
268
  findConfigFile(): string | null;
@@ -271,7 +273,6 @@ export declare class ConfigParser {
271
273
  validateConfig(config: ExplorbotConfig): void;
272
274
  getNestedValue(obj: any, path: string): any;
273
275
  mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig;
274
- deepMerge(target: any, source: any): any;
275
276
  ensureDirectory(path: string): void;
276
277
  }
277
278
  export declare function setOutputDir(dir: string): void;
@@ -13,8 +13,9 @@ import { pathToFileURL } from 'node:url';
13
13
  import { parseEnv } from 'node:util';
14
14
  import dedent from 'dedent';
15
15
  import matter from 'gray-matter';
16
- import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
16
+ import { EXPLORBOT_CONFIG_PATHS, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from './global-config.js';
17
17
  import { getCliName } from './utils/cli-name.js';
18
+ import { deepMerge } from './utils/merge.js';
18
19
  import { log, tag } from './utils/logger.js';
19
20
  export const PROVIDERS = {
20
21
  openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
@@ -37,7 +38,7 @@ const config = {
37
38
  model: null,
38
39
  },
39
40
  };
40
- export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
41
+ export { EXPLORBOT_CONFIG_PATHS };
41
42
  export const EXPLORBOT_ENV_VARS = [
42
43
  { name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
43
44
  { name: 'EXPLORBOT_AI_MODEL', description: 'Pins the main model — a model id for the provider, or a standalone provider/model-id' },
@@ -62,6 +63,7 @@ export class ConfigParser {
62
63
  runtimeTarget = null;
63
64
  site = null;
64
65
  siteStartPath = '/';
66
+ siteConfigPath = null;
65
67
  constructor() { }
66
68
  static loadEnv(filePath, keepExisting = false) {
67
69
  const resolved = resolve(filePath);
@@ -125,14 +127,16 @@ export class ConfigParser {
125
127
  sourcePath = join(outputRoot, 'explorbot.config.js');
126
128
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
127
129
  }
128
- this.config = this.resolveConfig(loadedConfig, options);
129
- await resolveConfigModels(this.config.ai);
130
- this.runtimeTarget = target;
131
- this.configPath = sourcePath;
130
+ let config = this.resolveConfig(loadedConfig, options);
131
+ await resolveConfigModels(config.ai);
132
132
  this.site = null;
133
+ this.siteConfigPath = null;
133
134
  if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
134
- this.enterGlobalMode(this.config, target);
135
+ config = await this.enterGlobalMode(config, target);
135
136
  }
137
+ this.config = config;
138
+ this.runtimeTarget = target;
139
+ this.configPath = sourcePath;
136
140
  this.applyEnvSpec(this.config);
137
141
  // Restore original directory after successful config load
138
142
  if (options?.path && originalCwd !== process.cwd()) {
@@ -186,6 +190,9 @@ export class ConfigParser {
186
190
  getSite() {
187
191
  return this.site;
188
192
  }
193
+ getSiteConfigPath() {
194
+ return this.siteConfigPath;
195
+ }
189
196
  resolveTargetPath(target) {
190
197
  if (!this.site) {
191
198
  const configured = this.config?.playwright?.url || this.config?.web?.url;
@@ -222,6 +229,7 @@ export class ConfigParser {
222
229
  ConfigParser.instance.runtimeTarget = null;
223
230
  ConfigParser.instance.site = null;
224
231
  ConfigParser.instance.siteStartPath = '/';
232
+ ConfigParser.instance.siteConfigPath = null;
225
233
  }
226
234
  }
227
235
  // For testing purposes only - sets up minimal default config
@@ -269,14 +277,20 @@ export class ConfigParser {
269
277
  // Ignore cleanup errors
270
278
  }
271
279
  }
272
- enterGlobalMode(config, target) {
280
+ async enterGlobalMode(config, target) {
273
281
  const site = resolveSiteTarget(target || undefined, config.web?.url || config.playwright?.url);
274
282
  this.site = registerSite(site.baseUrl);
275
283
  this.siteStartPath = site.path;
276
- config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
277
- config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
284
+ const { path: sitePath, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
285
+ this.siteConfigPath = sitePath;
286
+ const merged = deepMerge(config, siteConfig);
287
+ await resolveConfigModels(merged.ai);
288
+ resolveLangfuse(merged.ai);
289
+ merged.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
290
+ merged.playwright = { ...merged.playwright, browser: merged.playwright?.browser || 'chromium', url: site.baseUrl };
278
291
  materializeKnowledge(this.site.dir);
279
292
  log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
293
+ return merged;
280
294
  }
281
295
  applyEnvSpec(config) {
282
296
  const spec = process.env.EXPLORBOT_SPEC;
@@ -414,19 +428,7 @@ export class ConfigParser {
414
428
  output: 'output',
415
429
  },
416
430
  };
417
- return this.deepMerge(defaults, config);
418
- }
419
- deepMerge(target, source) {
420
- const result = { ...target };
421
- for (const key in source) {
422
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
423
- result[key] = this.deepMerge(result[key] || {}, source[key]);
424
- }
425
- else {
426
- result[key] = source[key];
427
- }
428
- }
429
- return result;
431
+ return deepMerge(defaults, config);
430
432
  }
431
433
  ensureDirectory(path) {
432
434
  if (!existsSync(path)) {
@@ -13,6 +13,7 @@ import { ConfigParser } from './config.js';
13
13
  import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
14
14
  import { createDebug, log, tag } from './utils/logger.js';
15
15
  import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
16
+ import { isInternalStep } from "./utils/step-analyzer.js";
16
17
  const debugLog = createDebug('explorbot:explorer');
17
18
  const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
18
19
  const RECOVERY_NAVIGATION = { waitUntil: 'domcontentloaded', timeout: 10000 };
@@ -161,9 +162,7 @@ class Explorer {
161
162
  const stepHandler = (step, status, error, log) => {
162
163
  if (!step.toCode)
163
164
  return;
164
- if (step?.name?.startsWith('grab'))
165
- return;
166
- if (step?.name?.startsWith('save'))
165
+ if (isInternalStep(step))
167
166
  return;
168
167
  test.addStep(step.toCode(), step.duration, status, error, log);
169
168
  if (!this.stateManager.getCurrentState())
@@ -1,3 +1,4 @@
1
+ export declare const EXPLORBOT_CONFIG_PATHS: string[];
1
2
  export declare function globalDir(): string;
2
3
  export declare function globalEnvPath(): string;
3
4
  export declare function globalConfigPath(): string;
@@ -6,6 +7,11 @@ export declare function isGlobalConfigPath(configPath: string): boolean;
6
7
  export declare function sitesDir(): string;
7
8
  export declare function siteFolderName(url: string): string;
8
9
  export declare function listSites(): SiteRecord[];
10
+ export declare function findSiteConfig(dir: string): string | null;
11
+ export declare function loadSiteConfig(dir: string, baseUrl: string): Promise<{
12
+ path: string;
13
+ config: any;
14
+ }>;
9
15
  export declare function findSiteWith(subpath: string): SiteRecord | undefined;
10
16
  export declare function listSitePlanDirs(): string[];
11
17
  export declare function registerSite(baseUrl: string): SiteRecord;
@@ -1,8 +1,19 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
10
  import os from 'node:os';
3
- import { join } from 'node:path';
11
+ import { join, resolve } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+ import dedent from 'dedent';
4
14
  const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
5
15
  const SITE_DIRS = ['knowledge', 'experience', 'output'];
16
+ export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
6
17
  export function globalDir() {
7
18
  return join(os.homedir(), '.explorbot');
8
19
  }
@@ -13,12 +24,7 @@ export function globalConfigPath() {
13
24
  return join(globalDir(), 'config.js');
14
25
  }
15
26
  export function findGlobalConfig() {
16
- for (const name of GLOBAL_CONFIG_NAMES) {
17
- const fullPath = join(globalDir(), name);
18
- if (existsSync(fullPath))
19
- return fullPath;
20
- }
21
- return null;
27
+ return firstExisting(globalDir(), GLOBAL_CONFIG_NAMES);
22
28
  }
23
29
  export function isGlobalConfigPath(configPath) {
24
30
  return GLOBAL_CONFIG_NAMES.some((name) => join(globalDir(), name) === configPath);
@@ -38,6 +44,47 @@ export function listSites() {
38
44
  .filter((site) => !!site)
39
45
  .sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
40
46
  }
47
+ export function findSiteConfig(dir) {
48
+ return firstExisting(dir, EXPLORBOT_CONFIG_PATHS);
49
+ }
50
+ function ensureSiteConfig(dir, baseUrl) {
51
+ const existing = findSiteConfig(dir);
52
+ if (existing)
53
+ return existing;
54
+ const path = join(dir, EXPLORBOT_CONFIG_PATHS[0]);
55
+ writeFileSync(path, siteConfigTemplate(baseUrl), 'utf8');
56
+ return path;
57
+ }
58
+ export async function loadSiteConfig(dir, baseUrl) {
59
+ const path = ensureSiteConfig(dir, baseUrl);
60
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(path)).href));
61
+ const config = module.default || module;
62
+ validateSiteConfig(config, path, baseUrl);
63
+ return { path, config };
64
+ }
65
+ function validateSiteConfig(config, configPath, baseUrl) {
66
+ const url = config?.web?.url;
67
+ if (!url) {
68
+ throw new Error(dedent `
69
+ Site config is missing web.url.
70
+ ${configPath}
71
+
72
+ Add it so the config states which site it configures:
73
+ web: { url: '${baseUrl}' },
74
+ `);
75
+ }
76
+ const declared = URL.parse(url)?.origin;
77
+ if (declared === baseUrl)
78
+ return;
79
+ throw new Error(dedent `
80
+ Site config declares a different site.
81
+ ${configPath}
82
+ web.url: ${url}
83
+ site: ${baseUrl}
84
+
85
+ Fix web.url, or explore ${url} to register it as its own site.
86
+ `);
87
+ }
41
88
  export function findSiteWith(subpath) {
42
89
  return listSites().find((site) => existsSync(join(site.dir, subpath)));
43
90
  }
@@ -88,6 +135,34 @@ export function resolveSiteTarget(target, defaultBaseUrl) {
88
135
  }
89
136
  return { baseUrl: site.url, path };
90
137
  }
138
+ function firstExisting(dir, names) {
139
+ for (const name of names) {
140
+ const fullPath = join(dir, name);
141
+ if (existsSync(fullPath))
142
+ return fullPath;
143
+ }
144
+ return null;
145
+ }
146
+ function siteConfigTemplate(baseUrl) {
147
+ return `// Config for ${baseUrl}
148
+ // Extends ~/.explorbot/config.js — set only what differs.
149
+ const config = {
150
+ web: {
151
+ url: '${baseUrl}',
152
+ },
153
+
154
+ // ai: {
155
+ // model: 'openrouter/openai/gpt-oss-120b',
156
+ // },
157
+
158
+ // playwright: {
159
+ // show: true,
160
+ // },
161
+ };
162
+
163
+ export default config;
164
+ `;
165
+ }
91
166
  function readSite(folder) {
92
167
  const dir = join(sitesDir(), folder);
93
168
  const metaPath = join(dir, 'site.json');
@@ -111,6 +111,7 @@ export class Reporter {
111
111
  }
112
112
  combineStepsAndNotes(test, lastScreenshotFile) {
113
113
  const noteEntries = Object.entries(test.notes)
114
+ .filter(([, note]) => !note.observation)
114
115
  .map(([timestampKey, note]) => ({
115
116
  startTime: note.startTime,
116
117
  endTime: note.endTime,
@@ -233,9 +234,12 @@ export class Reporter {
233
234
  description: test.description,
234
235
  code: test.generatedCode || '',
235
236
  steps,
236
- logs: Object.values(test.steps)
237
- .map((stepData) => stepData.text)
238
- .join('\n'),
237
+ logs: [
238
+ ...Object.values(test.steps).map((stepData) => stepData.text),
239
+ ...Object.values(test.notes)
240
+ .filter((note) => note.observation)
241
+ .map((note) => note.message),
242
+ ].join('\n'),
239
243
  files: Object.values(test.artifacts) || [],
240
244
  message: test.summary || this.extractLastNoteMessage(test) || '',
241
245
  meta,
@@ -300,7 +304,7 @@ export class Reporter {
300
304
  }
301
305
  }
302
306
  extractLastNoteMessage(test) {
303
- const notes = Object.values(test.notes);
307
+ const notes = Object.values(test.notes).filter((note) => !note.observation);
304
308
  if (notes.length === 0)
305
309
  return '';
306
310
  return notes[notes.length - 1].message;
@@ -1283,6 +1283,12 @@ function cleanElement(element) {
1283
1283
  'aria-labelledby',
1284
1284
  'aria-describedby',
1285
1285
  'aria-owns',
1286
+ 'aria-checked',
1287
+ 'aria-expanded',
1288
+ 'aria-selected',
1289
+ 'aria-pressed',
1290
+ 'aria-current',
1291
+ 'aria-disabled',
1286
1292
  'role',
1287
1293
  'title',
1288
1294
  'href',
@@ -189,7 +189,7 @@ class SpanDestination {
189
189
  if (!step?.toCode) {
190
190
  return;
191
191
  }
192
- const stepName = step?.name ? `I.${step.name}` : 'I.step';
192
+ const stepName = step?.title ? `I.${step.title}` : 'I.step';
193
193
  const stepInput = typeof step?.toCode === 'function' ? step.toCode() : entry.content;
194
194
  const errorFromStep = step?.error;
195
195
  const errorMessage = stepError && typeof stepError === 'object' && 'message' in stepError && typeof stepError.message === 'string'
@@ -0,0 +1 @@
1
+ export declare function deepMerge(target: any, source: any): any;
@@ -0,0 +1,11 @@
1
+ export function deepMerge(target, source) {
2
+ const result = { ...target };
3
+ for (const key in source) {
4
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
5
+ result[key] = deepMerge(result[key] || {}, source[key]);
6
+ continue;
7
+ }
8
+ result[key] = source[key];
9
+ }
10
+ return result;
11
+ }
@@ -9,3 +9,6 @@ export declare function stripComments(code: string): string;
9
9
  export declare function isNonReusableCode(code: string): boolean;
10
10
  export declare function toReusableSessionStep(step: StepData): SessionStep | null;
11
11
  export declare function mergeUniqueStepsByCode(primary: SessionStep[], secondary: SessionStep[]): SessionStep[];
12
+ export declare function isInternalStep(step: {
13
+ title?: string;
14
+ }): boolean;
@@ -1,5 +1,6 @@
1
1
  import { isDynamicId } from "./xpath.js";
2
2
  export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'];
3
+ const INTERNAL_STEP_PREFIXES = ['grab', 'save'];
3
4
  const CODECEPT_FORM_COMMANDS = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
4
5
  export function isCodeceptToolName(toolName) {
5
6
  return CODECEPT_TOOLS.includes(toolName);
@@ -66,3 +67,9 @@ export function mergeUniqueStepsByCode(primary, secondary) {
66
67
  }
67
68
  return merged;
68
69
  }
70
+ export function isInternalStep(step) {
71
+ const title = step?.title;
72
+ if (!title)
73
+ return false;
74
+ return INTERNAL_STEP_PREFIXES.some((prefix) => title.startsWith(prefix));
75
+ }
@@ -6,3 +6,4 @@ export declare function generalizeUrl(url: string, replaceSegment?: (segment: st
6
6
  export declare function matchesUrl(pattern: string, path: string): boolean;
7
7
  export declare function extractStatePath(url: string): string;
8
8
  export declare function matchesNavigationUrl(expected: string, current: string): boolean;
9
+ export declare function isSameHostFamily(urlA: string, urlB: string): boolean;
@@ -145,3 +145,10 @@ export function matchesNavigationUrl(expected, current) {
145
145
  .filter(Boolean);
146
146
  return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
147
147
  }
148
+ export function isSameHostFamily(urlA, urlB) {
149
+ const hostA = URL.parse(urlA)?.host.toLowerCase();
150
+ const hostB = URL.parse(urlB)?.host.toLowerCase();
151
+ if (!hostA || !hostB)
152
+ return false;
153
+ return hostA === hostB || hostA.endsWith(`.${hostB}`) || hostB.endsWith(`.${hostA}`);
154
+ }
@@ -127,7 +127,7 @@ EXPLORBOT_AI_PROVIDER=openrouter \
127
127
 
128
128
  `npx explorbot recommended-models` prints, per provider, the model this version recommends for each role, and the two ways to select it. Both need the provider's API key exported. Set `EXPLORBOT_AI_PROVIDER=<name>` and every role takes that provider's recommendation; leave it out and pin the roles yourself with `EXPLORBOT_AI_MODEL`, `EXPLORBOT_VISION_MODEL` and `EXPLORBOT_AGENTIC_MODEL`, each written as `provider/model-id` — the command prints those three lines filled in, ready to paste. A role a provider does not serve is named as such, so you know to pair it with another. It closes with the model variables and provider keys currently exported, and a ready-to-run OpenRouter one-liner. It reads nothing but the bundled recommendations, so it answers before any configuration exists and every CLI carries it: `npx explorbot api recommended-models`, `npx explorbot docs recommended-models`, `npx prima recommended-models`. `--json` prints the bundled recommendations as an object.
129
129
 
130
- Explorbot resolves its configuration in this order: the path given to `--config`, then `explorbot.config.*` in the working directory, then the `EXPLORBOT_*` variables, and finally `~/.explorbot/config.*` from the global installation. A bare provider name fills every model role from the recommendations in [Providers](../basics/providers.md); a `provider/model-id` spec pins one model and splits on the first slash, so `openrouter/openai/gpt-oss-120b:nitro` selects OpenRouter with model `openai/gpt-oss-120b:nitro`. Supported providers: `openai`, `anthropic`, `google`, `groq`, `mistral`, `openrouter`, `sambanova`.
130
+ Explorbot resolves its configuration in this order: the path given to `--config`, then `explorbot.config.*` in the working directory, then the `EXPLORBOT_*` variables, and finally `~/.explorbot/config.*` from the global installation, which each site then extends with its own [per-site config](configuration.md#per-site-configuration). A bare provider name fills every model role from the recommendations in [Providers](../basics/providers.md); a `provider/model-id` spec pins one model and splits on the first slash, so `openrouter/openai/gpt-oss-120b:nitro` selects OpenRouter with model `openai/gpt-oss-120b:nitro`. Supported providers: `openai`, `anthropic`, `google`, `groq`, `mistral`, `openrouter`, `sambanova`.
131
131
 
132
132
  In this mode output goes to `~/.explorbot/sites/<host>/output/` (or `EXPLORBOT_OUTPUT`, or a temp directory with `EXPLORBOT_EPHEMERAL=1`), experience is kept beside it unless the run is ephemeral, and the Historian is off, so no generated test files appear. See [Agentic Usage](../workflow/agentic-usage.md) for the full picture.
133
133
 
@@ -934,12 +934,20 @@ See [Configuration](configuration.md#running-from-anywhere-the-global-installati
934
934
 
935
935
  ### `npx explorbot sites`
936
936
 
937
- List the sites registered in the global installation — folder name, base URL, and last run. Sites register themselves the first time you explore them by URL.
937
+ List the sites registered in the global installation — folder name, base URL, last run, and the [per-site config](configuration.md#per-site-configuration) each one uses. Sites register themselves the first time you explore them by URL.
938
938
 
939
939
  ```bash
940
940
  npx explorbot sites
941
941
  ```
942
942
 
943
+ ```
944
+ Registered sites (2):
945
+ app.example.com https://app.example.com last run 2026-09-16 09:57
946
+ /home/you/.explorbot/sites/app.example.com/explorbot.config.js
947
+ other.example.com https://other.example.com last run 2026-09-01 10:00
948
+ inherits global config
949
+ ```
950
+
943
951
  ### `npx explorbot clean [target]`
944
952
 
945
953
  Clean generated files. Targets: `states`, `research`, `plans`, `tests`, `experiences`, `output`.
@@ -413,7 +413,7 @@ Explorbot looks for a config file in this order:
413
413
  7. `src/config/explorbot.config.js`
414
414
  8. `src/config/explorbot.config.mjs`
415
415
  9. `src/config/explorbot.config.ts`
416
- 10. `~/.explorbot/config.js` (or `.mjs`, `.ts`) — the global installation
416
+ 10. `~/.explorbot/config.js` (or `.mjs`, `.ts`) — the global installation, extended per site by `~/.explorbot/sites/<host>/explorbot.config.js`
417
417
 
418
418
  Or pass a custom path:
419
419
 
@@ -431,14 +431,15 @@ Env files fill in rather than override: the `.env` of the working directory is r
431
431
 
432
432
  ```
433
433
  ~/.explorbot/
434
- ├── config.js # AI models and keys, no URL
434
+ ├── config.js # AI models and keys, shared by every site
435
435
  ├── .env
436
436
  └── sites/
437
437
  ├── app.example.com/
438
- │ ├── site.json # base URL, first and last run
438
+ │ ├── explorbot.config.js # this site's settings, extends config.js
439
+ │ ├── site.json # base URL, first and last run
439
440
  │ ├── knowledge/
440
441
  │ ├── experience/
441
- │ └── output/ # states, plans, reports, tests
442
+ │ └── output/ # states, plans, reports, tests
442
443
  └── localhost_3000/
443
444
  ```
444
445
 
@@ -456,6 +457,38 @@ npx explorbot sites # list registered sites
456
457
 
457
458
  A `dirs` section in the global config is ignored in favor of the layout above. A `web.url` is allowed and acts as the default site for commands that pass no URL of their own.
458
459
 
460
+ #### Per-site configuration
461
+
462
+ `~/.explorbot/config.js` holds what every site shares — models, keys, reporter settings. Anything one site needs differently goes in its own `explorbot.config.js`, written into the site folder the first time that site is explored:
463
+
464
+ ```javascript
465
+ // Config for https://app.example.com
466
+ // Extends ~/.explorbot/config.js — set only what differs.
467
+ const config = {
468
+ web: {
469
+ url: 'https://app.example.com',
470
+ },
471
+
472
+ ai: {
473
+ model: 'openrouter/anthropic/claude-sonnet-5',
474
+ },
475
+
476
+ playwright: {
477
+ show: true,
478
+ },
479
+ };
480
+
481
+ export default config;
482
+ ```
483
+
484
+ The two are merged section by section, and the site wins. A site that overrides `ai.model` keeps the global `ai.visionModel`. Use it to give a slow or unusual app a stronger model, a visible browser, or its own reporter settings, without changing how every other site runs.
485
+
486
+ `web.url` is required, and must be the site the folder belongs to — it is what makes the file readable on its own rather than meaningful only by where it sits. Explorbot refuses to run when it is missing or names a different site, instead of quietly ignoring the mismatch. To configure a different site, explore it and edit the config in its own folder.
487
+
488
+ `dirs` and the base URL stay owned by the layout above and cannot be overridden. The file is never rewritten once created, and a site without one simply uses the global config.
489
+
490
+ Per-site configs apply to the global installation only. A directory with its own `explorbot.config.js` and the `EXPLORBOT_*` environment mode below both resolve to a single config with nothing to extend.
491
+
459
492
  ### Running without a config file
460
493
 
461
494
  When the working directory has no config file and `EXPLORBOT_AI_PROVIDER` (or `EXPLORBOT_AI_MODEL`) is set, Explorbot synthesizes a configuration from `EXPLORBOT_*` environment variables, in preference to a global installation. Output goes to the site folder `~/.explorbot/sites/<host>/` (`EXPLORBOT_OUTPUT` overrides it, `EXPLORBOT_EPHEMERAL=1` sends it to a temp directory instead), experience is written there and reused by later runs against the same host unless the run is ephemeral, and the Historian is off. This is meant for one-liner CI jobs, demos, and coding agents — see [Agentic Usage](../workflow/agentic-usage.md) for the variable list and the trade-offs.