explorbot 0.4.8 → 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.
@@ -40,7 +40,7 @@ export function createApiCommands(name = 'api'): Command {
40
40
  if (runOptions.endpoint && URL.canParse(runOptions.endpoint)) runOptions.baseEndpoint ||= runOptions.endpoint;
41
41
  try {
42
42
  const config = await parser.loadConfig(runOptions);
43
- console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
43
+ console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
44
44
  } catch (error) {
45
45
  console.error(error instanceof Error ? error.message : 'Unknown error');
46
46
  process.exit(1);
@@ -18,7 +18,8 @@ import {
18
18
  resolveOutputRoot,
19
19
  setOutputDir,
20
20
  } from '../../../src/config.ts';
21
- import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
21
+ import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
22
+ import { deepMerge } from '../../../src/utils/merge.ts';
22
23
 
23
24
  export type { AIConfig };
24
25
 
@@ -58,6 +59,7 @@ export class ApibotConfigParser {
58
59
  private config: ApibotConfig | null = null;
59
60
  private configPath: string | null = null;
60
61
  private site: SiteRecord | null = null;
62
+ private siteConfigPath: string | null = null;
61
63
 
62
64
  private constructor() {}
63
65
 
@@ -111,20 +113,22 @@ export class ApibotConfigParser {
111
113
  };
112
114
  }
113
115
 
114
- this.config = this.mergeWithDefaults(loadedConfig);
115
- this.applyEnvSpec(this.config.api);
116
- this.applyEnvHeaders(this.config.api);
117
- if (options?.baseEndpoint) this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
118
- await resolveConfigModels(this.config.ai);
119
- resolveLangfuse(this.config.ai);
120
- this.configPath = resolvedPath;
116
+ let config = this.mergeWithDefaults(loadedConfig);
117
+ this.applyEnvSpec(config.api);
118
+ this.applyEnvHeaders(config.api);
119
+ if (options?.baseEndpoint) config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
120
+ await resolveConfigModels(config.ai);
121
+ resolveLangfuse(config.ai);
121
122
  this.site = null;
123
+ this.siteConfigPath = null;
122
124
 
123
125
  if (isGlobalConfigPath(resolvedPath)) {
124
- this.enterGlobalMode(this.config, options?.endpoint);
126
+ config = await this.enterGlobalMode(config, options?.endpoint);
125
127
  }
126
128
 
127
- this.validateConfig(this.config);
129
+ this.validateConfig(config);
130
+ this.config = config;
131
+ this.configPath = resolvedPath;
128
132
  setOutputDir(this.getOutputDir());
129
133
 
130
134
  return this.config;
@@ -144,6 +148,10 @@ export class ApibotConfigParser {
144
148
  return this.configPath;
145
149
  }
146
150
 
151
+ getSiteConfigPath(): string | null {
152
+ return this.siteConfigPath;
153
+ }
154
+
147
155
  getOutputDir(): string {
148
156
  const config = this.getConfig();
149
157
  return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
@@ -207,17 +215,29 @@ export class ApibotConfigParser {
207
215
  api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
208
216
  }
209
217
 
210
- private enterGlobalMode(config: ApibotConfig, endpoint?: string): void {
218
+ private async enterGlobalMode(config: ApibotConfig, endpoint?: string): Promise<ApibotConfig> {
211
219
  const site = resolveSiteTarget(endpoint);
212
220
  this.site = registerSite(site.baseUrl);
213
221
 
222
+ const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
223
+ this.siteConfigPath = path;
224
+
225
+ const overrides: Partial<ApibotConfig> = {};
226
+ if (siteConfig.ai) overrides.ai = siteConfig.ai;
227
+ if (siteConfig.api) overrides.api = siteConfig.api;
228
+
229
+ const merged = deepMerge(config, overrides);
230
+ await resolveConfigModels(merged.ai);
231
+ resolveLangfuse(merged.ai);
232
+
214
233
  let baseEndpoint = site.baseUrl;
215
234
  const envUrl = process.env.EXPLORBOT_URL;
216
235
  if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl) baseEndpoint = envUrl.replace(/\/$/, '');
217
236
 
218
- config.dirs = { output: 'output', knowledge: 'knowledge' };
219
- config.api = { ...config.api, baseEndpoint };
237
+ merged.dirs = { output: 'output', knowledge: 'knowledge' };
238
+ merged.api = { ...merged.api, baseEndpoint };
220
239
  materializeKnowledge(this.site.dir);
240
+ return merged;
221
241
  }
222
242
 
223
243
  private async loadEnvConfig(): Promise<ApibotConfig> {
@@ -310,19 +330,7 @@ export class ApibotConfigParser {
310
330
  }
311
331
 
312
332
  private mergeWithDefaults(config: Partial<ApibotConfig>): ApibotConfig {
313
- return this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
314
- }
315
-
316
- private deepMerge(target: any, source: any): any {
317
- const result = { ...target };
318
- for (const key in source) {
319
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
320
- result[key] = this.deepMerge(result[key] || {}, source[key]);
321
- } else {
322
- result[key] = source[key];
323
- }
324
- }
325
- return result;
333
+ return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
326
334
  }
327
335
  }
328
336
 
@@ -38,7 +38,7 @@ export function createApiCommands(name = 'api') {
38
38
  runOptions.baseEndpoint ||= runOptions.endpoint;
39
39
  try {
40
40
  const config = await parser.loadConfig(runOptions);
41
- console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
41
+ console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
42
42
  }
43
43
  catch (error) {
44
44
  console.error(error instanceof Error ? error.message : 'Unknown error');
@@ -11,7 +11,8 @@ import path, { resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { parseEnv } from 'node:util';
13
13
  import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveLangfuse, resolveModel, resolveOutputRoot, setOutputDir, } from "../../../src/config.js";
14
- import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
14
+ import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
15
+ import { deepMerge } from "../../../src/utils/merge.js";
15
16
  function isAbsoluteEndpoint(value) {
16
17
  return !!value && (value.startsWith('http://') || value.startsWith('https://'));
17
18
  }
@@ -30,6 +31,7 @@ export class ApibotConfigParser {
30
31
  config = null;
31
32
  configPath = null;
32
33
  site = null;
34
+ siteConfigPath = null;
33
35
  constructor() { }
34
36
  static getInstance() {
35
37
  if (!ApibotConfigParser.instance) {
@@ -76,19 +78,21 @@ export class ApibotConfigParser {
76
78
  dirs: loadedConfig.dirs,
77
79
  };
78
80
  }
79
- this.config = this.mergeWithDefaults(loadedConfig);
80
- this.applyEnvSpec(this.config.api);
81
- this.applyEnvHeaders(this.config.api);
81
+ let config = this.mergeWithDefaults(loadedConfig);
82
+ this.applyEnvSpec(config.api);
83
+ this.applyEnvHeaders(config.api);
82
84
  if (options?.baseEndpoint)
83
- this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
84
- await resolveConfigModels(this.config.ai);
85
- resolveLangfuse(this.config.ai);
86
- this.configPath = resolvedPath;
85
+ config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
86
+ await resolveConfigModels(config.ai);
87
+ resolveLangfuse(config.ai);
87
88
  this.site = null;
89
+ this.siteConfigPath = null;
88
90
  if (isGlobalConfigPath(resolvedPath)) {
89
- this.enterGlobalMode(this.config, options?.endpoint);
91
+ config = await this.enterGlobalMode(config, options?.endpoint);
90
92
  }
91
- this.validateConfig(this.config);
93
+ this.validateConfig(config);
94
+ this.config = config;
95
+ this.configPath = resolvedPath;
92
96
  setOutputDir(this.getOutputDir());
93
97
  return this.config;
94
98
  }
@@ -106,6 +110,9 @@ export class ApibotConfigParser {
106
110
  getConfigPath() {
107
111
  return this.configPath;
108
112
  }
113
+ getSiteConfigPath() {
114
+ return this.siteConfigPath;
115
+ }
109
116
  getOutputDir() {
110
117
  const config = this.getConfig();
111
118
  return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
@@ -168,16 +175,27 @@ export class ApibotConfigParser {
168
175
  return;
169
176
  api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
170
177
  }
171
- enterGlobalMode(config, endpoint) {
178
+ async enterGlobalMode(config, endpoint) {
172
179
  const site = resolveSiteTarget(endpoint);
173
180
  this.site = registerSite(site.baseUrl);
181
+ const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
182
+ this.siteConfigPath = path;
183
+ const overrides = {};
184
+ if (siteConfig.ai)
185
+ overrides.ai = siteConfig.ai;
186
+ if (siteConfig.api)
187
+ overrides.api = siteConfig.api;
188
+ const merged = deepMerge(config, overrides);
189
+ await resolveConfigModels(merged.ai);
190
+ resolveLangfuse(merged.ai);
174
191
  let baseEndpoint = site.baseUrl;
175
192
  const envUrl = process.env.EXPLORBOT_URL;
176
193
  if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl)
177
194
  baseEndpoint = envUrl.replace(/\/$/, '');
178
- config.dirs = { output: 'output', knowledge: 'knowledge' };
179
- config.api = { ...config.api, baseEndpoint };
195
+ merged.dirs = { output: 'output', knowledge: 'knowledge' };
196
+ merged.api = { ...merged.api, baseEndpoint };
180
197
  materializeKnowledge(this.site.dir);
198
+ return merged;
181
199
  }
182
200
  async loadEnvConfig() {
183
201
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
@@ -261,18 +279,6 @@ export class ApibotConfigParser {
261
279
  }
262
280
  }
263
281
  mergeWithDefaults(config) {
264
- return this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
265
- }
266
- deepMerge(target, source) {
267
- const result = { ...target };
268
- for (const key in source) {
269
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
270
- result[key] = this.deepMerge(result[key] || {}, source[key]);
271
- }
272
- else {
273
- result[key] = source[key];
274
- }
275
- }
276
- return result;
282
+ return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
277
283
  }
278
284
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -196,13 +196,19 @@ export function createAskApiTool(fisherman, task) {
196
196
  return {
197
197
  askApi: tool({
198
198
  description: dedent `
199
- Ask what data already exists, changing nothing.
200
- Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
201
- Use it before precondition() to see whether suitable data is already available, and whenever a step needs the exact name or id of a record that is already there.
199
+ Read the app's data over the API, changing nothing.
200
+ Answers questions about records: which ones are there, what they are called, whether a particular one exists.
201
+
202
+ Use it to:
203
+ - check whether suitable data already exists, before precondition() creates any
204
+ - get the exact name or id of a record a step must act on
205
+ - find out whether an action was stored, when the app reported success but the page does not show the result
206
+ - find out whether data exists at all, when a list or dropdown is empty
207
+
202
208
  It never creates, edits or deletes anything — precondition() does that.
203
209
  `,
204
210
  inputSchema: z.object({
205
- question: z.string().describe('What to find out about data that already exists'),
211
+ question: z.string().describe('What to find out about the data'),
206
212
  }),
207
213
  execute: async ({ question }) => {
208
214
  tag('info').log(`Ask API: ${question}`);
@@ -922,14 +922,14 @@ export class Pilot {
922
922
  const lines = [];
923
923
  for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
924
924
  if (passed)
925
- lines.push(`state verification (passed): ${assertion}`);
925
+ lines.push(`verify: ${assertion}`);
926
926
  }
927
927
  for (const exec of testerConversation.getToolExecutions()) {
928
928
  if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
929
929
  continue;
930
930
  const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
931
- const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
932
- lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
931
+ const analysis = exec.output?.analysis;
932
+ lines.push(`${exec.toolName}: ${description}${analysis ? ` -> ${analysis}` : ''}`);
933
933
  }
934
934
  return [...new Set(lines)].join('\n');
935
935
  }
@@ -1053,9 +1053,14 @@ export class Pilot {
1053
1053
 
1054
1054
  YOUR Pilot-only tools, both over the API:
1055
1055
 
1056
- askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
1057
- suitable data is already there before creating any, and to get the exact name or id of an existing
1058
- record a step must act on.
1056
+ askApi(question) — read the app's data over the API. It changes nothing. Use when:
1057
+
1058
+ - Before precondition() check whether suitable data already exists.
1059
+ - A step needs the exact name or id of an existing record.
1060
+ - The app reported success but the page does not show the result — ask whether it was stored.
1061
+ - A list or dropdown is empty — ask whether the data exists at all.
1062
+
1063
+ The page is not the only witness. A record missing from the page may still exist.
1059
1064
 
1060
1065
  precondition(description) — create FRESH disposable test data. Never request users. Use when:
1061
1066
 
@@ -1202,7 +1202,8 @@ async function extractWebElements(error) {
1202
1202
  function formatElementList(matched) {
1203
1203
  if (!matched)
1204
1204
  return 'Could not fetch element details. Repeat the action to get better info.';
1205
- return matched
1205
+ const keys = matched.map((el) => `${el.text}::${el.visible}::${el.html}`);
1206
+ const list = matched
1206
1207
  .map((el, i) => {
1207
1208
  const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1208
1209
  if (el.visible !== undefined)
@@ -1210,10 +1211,16 @@ function formatElementList(matched) {
1210
1211
  const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
1211
1212
  if (wrapped.length)
1212
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(', ')}`);
1213
1217
  lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1214
1218
  return lines.join('\n');
1215
1219
  })
1216
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().`;
1217
1224
  }
1218
1225
  export async function formatMatchedElements(error) {
1219
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)) {