explorbot 0.4.7 โ†’ 0.4.9

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 (59) hide show
  1. package/boat/api-tester/src/ai/chief.ts +3 -1
  2. package/boat/api-tester/src/ai/curler.ts +4 -0
  3. package/boat/api-tester/src/cli.ts +1 -1
  4. package/boat/api-tester/src/config.ts +34 -26
  5. package/dist/boat/api-tester/src/ai/chief.js +3 -1
  6. package/dist/boat/api-tester/src/ai/curler.js +4 -0
  7. package/dist/boat/api-tester/src/cli.js +1 -1
  8. package/dist/boat/api-tester/src/config.js +32 -26
  9. package/dist/package.json +1 -1
  10. package/dist/rules/chief/general.md +2 -0
  11. package/dist/rules/researcher/pagination.md +1 -0
  12. package/dist/src/action.js +3 -2
  13. package/dist/src/ai/fisherman/tools.js +10 -4
  14. package/dist/src/ai/navigator.js +1 -4
  15. package/dist/src/ai/pilot.js +19 -18
  16. package/dist/src/ai/planner/session-dedup.d.ts +2 -1
  17. package/dist/src/ai/planner/session-dedup.js +18 -1
  18. package/dist/src/ai/planner.js +8 -4
  19. package/dist/src/ai/provider.js +18 -4
  20. package/dist/src/ai/rules.js +1 -0
  21. package/dist/src/ai/tester.js +1 -1
  22. package/dist/src/ai/tools.js +10 -2
  23. package/dist/src/commands/config-command.d.ts +2 -0
  24. package/dist/src/commands/config-command.js +8 -2
  25. package/dist/src/commands/init-command.js +6 -26
  26. package/dist/src/commands/sites-command.js +6 -1
  27. package/dist/src/config.d.ts +5 -4
  28. package/dist/src/config.js +25 -23
  29. package/dist/src/global-config.d.ts +6 -0
  30. package/dist/src/global-config.js +82 -7
  31. package/dist/src/utils/code-extractor.js +6 -2
  32. package/dist/src/utils/html.js +6 -0
  33. package/dist/src/utils/merge.d.ts +1 -0
  34. package/dist/src/utils/merge.js +11 -0
  35. package/docs/reference/commands.md +10 -2
  36. package/docs/reference/configuration.md +37 -4
  37. package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
  38. package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
  39. package/package.json +1 -1
  40. package/rules/chief/general.md +2 -0
  41. package/rules/researcher/pagination.md +1 -0
  42. package/src/action.ts +3 -2
  43. package/src/ai/fisherman/tools.ts +10 -4
  44. package/src/ai/navigator.ts +1 -4
  45. package/src/ai/pilot.ts +19 -18
  46. package/src/ai/planner/session-dedup.ts +16 -2
  47. package/src/ai/planner.ts +8 -4
  48. package/src/ai/provider.ts +18 -3
  49. package/src/ai/rules.ts +1 -0
  50. package/src/ai/tester.ts +1 -1
  51. package/src/ai/tools.ts +9 -2
  52. package/src/commands/config-command.ts +9 -2
  53. package/src/commands/init-command.ts +6 -27
  54. package/src/commands/sites-command.ts +6 -1
  55. package/src/config.ts +28 -25
  56. package/src/global-config.ts +81 -6
  57. package/src/utils/code-extractor.ts +6 -2
  58. package/src/utils/html.ts +6 -0
  59. package/src/utils/merge.ts +13 -0
@@ -29,7 +29,7 @@ const SAMPLE_FILES = {
29
29
  };
30
30
  export class Tester extends TaskAgent {
31
31
  ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
32
- DELEGATED_ACTION_TOOLS = ['interact'];
32
+ DELEGATED_ACTION_TOOLS = ['interact', 'visualClick'];
33
33
  SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
34
34
  emoji = '๐Ÿงช';
35
35
  requestStore;
@@ -312,6 +312,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
312
312
  - Performing multiple form actions in a single batch
313
313
  - Complex interactions requiring sequential commands
314
314
  - Reaching items further down a list (I.scrollTo)
315
+ - Reloading the page to prove a change outlived it (I.reloadPage)
315
316
 
316
317
  Example - filling a form with context (PREFERRED):
317
318
  I.fillField('Username', 'John', '.login-form')
@@ -324,7 +325,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
324
325
  I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
325
326
 
326
327
  Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
327
- Do not use: wait functions, amOnPage, reloadPage, saveScreenshot
328
+ Do not use: wait functions, amOnPage, saveScreenshot
328
329
  `,
329
330
  inputSchema: z.object({
330
331
  codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
@@ -1201,7 +1202,8 @@ async function extractWebElements(error) {
1201
1202
  function formatElementList(matched) {
1202
1203
  if (!matched)
1203
1204
  return 'Could not fetch element details. Repeat the action to get better info.';
1204
- return matched
1205
+ const keys = matched.map((el) => `${el.text}::${el.visible}::${el.html}`);
1206
+ const list = matched
1205
1207
  .map((el, i) => {
1206
1208
  const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1207
1209
  if (el.visible !== undefined)
@@ -1209,10 +1211,16 @@ function formatElementList(matched) {
1209
1211
  const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
1210
1212
  if (wrapped.length)
1211
1213
  lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
1214
+ const same = keys.map((_, j) => j).filter((j) => j !== i && keys[j] === keys[i]);
1215
+ if (same.length)
1216
+ lines.push(`Identical to element ${same.map((j) => j + 1).join(', ')}`);
1212
1217
  lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1213
1218
  return lines.join('\n');
1214
1219
  })
1215
1220
  .join('\n\n');
1221
+ if (new Set(keys).size === matched.length)
1222
+ return list;
1223
+ return `${list}\n\nIdentical matches are not told apart by their number โ€” picking one is a guess. Click the one you mean by appearance with visualClick().`;
1216
1224
  }
1217
1225
  export async function formatMatchedElements(error) {
1218
1226
  return formatElementList(await extractWebElements(error));
@@ -15,11 +15,13 @@ export declare class ConfigCommand extends BaseCommand {
15
15
  }
16
16
  interface ConfigSummaryOptions {
17
17
  configPath?: string | null;
18
+ siteConfigPath?: string | null;
18
19
  root?: string;
19
20
  json?: boolean;
20
21
  }
21
22
  export interface ConfigData {
22
23
  config: string;
24
+ siteConfig: string;
23
25
  url: string;
24
26
  browser: string;
25
27
  headless: boolean;
@@ -13,7 +13,7 @@ export class ConfigCommand extends BaseCommand {
13
13
  description = 'Show models, config file and paths used by this run';
14
14
  async execute() {
15
15
  const parser = ConfigParser.getInstance();
16
- tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
16
+ tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot() }));
17
17
  }
18
18
  static async summary(options = {}) {
19
19
  const parser = ConfigParser.getInstance();
@@ -24,12 +24,15 @@ export class ConfigCommand extends BaseCommand {
24
24
  throw error;
25
25
  return load(site.url);
26
26
  });
27
- return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
27
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json });
28
28
  }
29
29
  static data(config, options = {}) {
30
30
  let configPath = '';
31
31
  if (options.configPath && existsSync(options.configPath))
32
32
  configPath = options.configPath;
33
+ let siteConfigPath = '';
34
+ if (options.siteConfigPath && existsSync(options.siteConfigPath))
35
+ siteConfigPath = options.siteConfigPath;
33
36
  const dirs = {};
34
37
  if (options.root) {
35
38
  for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
@@ -55,6 +58,7 @@ export class ConfigCommand extends BaseCommand {
55
58
  }
56
59
  return {
57
60
  config: configPath,
61
+ siteConfig: siteConfigPath,
58
62
  url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
59
63
  browser: config.playwright?.browser || '',
60
64
  headless: !config.playwright?.show,
@@ -75,6 +79,8 @@ export class ConfigCommand extends BaseCommand {
75
79
  const lines = [];
76
80
  const section = (title, entries) => lines.push(...renderSection(title, entries));
77
81
  const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
82
+ if (data.siteConfig)
83
+ general.push(['site config', data.siteConfig]);
78
84
  if (data.url)
79
85
  general.push(['url', data.url]);
80
86
  if (data.browser) {
@@ -6,10 +6,7 @@ import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "..
6
6
  import { getCliName } from "../utils/cli-name.js";
7
7
  import { log, tag } from '../utils/logger.js';
8
8
  import { relativeToCwd } from "../utils/next-steps.js";
9
- function defaultConfigTemplate(provider, esm) {
10
- let moduleExport = 'module.exports = config;';
11
- if (esm)
12
- moduleExport = 'export default config;';
9
+ function defaultConfigTemplate(provider) {
13
10
  return `// 'provider/model-id' uses a bundled provider.
14
11
  // It is also possible to import provider as a module from Vercel AI SDK.
15
12
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
@@ -34,7 +31,7 @@ ${modelLines(provider)}
34
31
  },
35
32
  };
36
33
 
37
- ${moduleExport}
34
+ export default config;
38
35
  `;
39
36
  }
40
37
  export function envTemplate(provider) {
@@ -128,8 +125,7 @@ export function runInitCommand(options) {
128
125
  log('Use --force to overwrite existing file');
129
126
  process.exit(1);
130
127
  }
131
- const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
132
- writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
128
+ writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
133
129
  log(`Created config file: ${relativeToCwd(outPath)}`);
134
130
  const envPath = resolve(process.cwd(), '.env');
135
131
  if (!existsSync(envPath)) {
@@ -234,6 +230,8 @@ export function modelLines(provider, only) {
234
230
  function globalConfigTemplate(provider) {
235
231
  const { envKey } = PROVIDERS[provider];
236
232
  return `// Global Explorbot configuration โ€” used by every directory without its own explorbot.config.js.
233
+ // Settings shared by every site. Each site extends them in
234
+ // ~/.explorbot/sites/<host>/explorbot.config.js, written on its first run.
237
235
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
238
236
  // The key is read from ${envKey} in ~/.explorbot/.env
239
237
  // Model ids are snapshotted from the recommendations of this Explorbot version.
@@ -251,27 +249,9 @@ ${modelLines(provider)}
251
249
  },
252
250
  };
253
251
 
254
- module.exports = config;
252
+ export default config;
255
253
  `;
256
254
  }
257
- function isModuleProject(configDir) {
258
- let currentDir = resolve(configDir);
259
- while (true) {
260
- const packagePath = join(currentDir, 'package.json');
261
- if (existsSync(packagePath)) {
262
- try {
263
- return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
264
- }
265
- catch {
266
- return false;
267
- }
268
- }
269
- const parentDir = dirname(currentDir);
270
- if (parentDir === currentDir)
271
- return false;
272
- currentDir = parentDir;
273
- }
274
- }
275
255
  function writeEnvKey(key, value) {
276
256
  const envPath = globalEnvPath();
277
257
  let content = '# AI provider API keys';
@@ -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)) {
@@ -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');
@@ -1,11 +1,15 @@
1
1
  import { createDebug } from './logger.js';
2
2
  const debugLog = createDebug('explorbot:code-extractor');
3
+ const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
3
4
  export function extractCodeBlocks(aiResponse) {
4
- const codeBlockRegex = /```(?:js|javascript)?\s*\n([\s\S]*?)\n```/g;
5
+ const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
5
6
  const codeBlocks = [];
6
7
  let match = null;
7
8
  while ((match = codeBlockRegex.exec(aiResponse))) {
8
- const code = match[1].trim();
9
+ const language = match[1].trim().toLowerCase();
10
+ if (!JS_LANGUAGES.has(language))
11
+ continue;
12
+ const code = match[2].trim();
9
13
  if (!code)
10
14
  continue;
11
15
  try {
@@ -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',
@@ -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
+ }
@@ -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.