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.
- package/boat/api-tester/src/ai/chief.ts +3 -1
- package/boat/api-tester/src/ai/curler.ts +4 -0
- package/boat/api-tester/src/cli.ts +1 -1
- package/boat/api-tester/src/config.ts +34 -26
- package/dist/boat/api-tester/src/ai/chief.js +3 -1
- package/dist/boat/api-tester/src/ai/curler.js +4 -0
- package/dist/boat/api-tester/src/cli.js +1 -1
- package/dist/boat/api-tester/src/config.js +32 -26
- package/dist/package.json +1 -1
- package/dist/rules/chief/general.md +2 -0
- package/dist/rules/researcher/pagination.md +1 -0
- package/dist/src/action.js +3 -2
- package/dist/src/ai/fisherman/tools.js +10 -4
- package/dist/src/ai/navigator.js +1 -4
- package/dist/src/ai/pilot.js +19 -18
- package/dist/src/ai/planner/session-dedup.d.ts +2 -1
- package/dist/src/ai/planner/session-dedup.js +18 -1
- package/dist/src/ai/planner.js +8 -4
- package/dist/src/ai/provider.js +18 -4
- package/dist/src/ai/rules.js +1 -0
- package/dist/src/ai/tester.js +1 -1
- package/dist/src/ai/tools.js +10 -2
- package/dist/src/commands/config-command.d.ts +2 -0
- package/dist/src/commands/config-command.js +8 -2
- package/dist/src/commands/init-command.js +6 -26
- package/dist/src/commands/sites-command.js +6 -1
- package/dist/src/config.d.ts +5 -4
- package/dist/src/config.js +25 -23
- package/dist/src/global-config.d.ts +6 -0
- package/dist/src/global-config.js +82 -7
- package/dist/src/utils/code-extractor.js +6 -2
- package/dist/src/utils/html.js +6 -0
- package/dist/src/utils/merge.d.ts +1 -0
- package/dist/src/utils/merge.js +11 -0
- package/docs/reference/commands.md +10 -2
- package/docs/reference/configuration.md +37 -4
- package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
- package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
- package/package.json +1 -1
- package/rules/chief/general.md +2 -0
- package/rules/researcher/pagination.md +1 -0
- package/src/action.ts +3 -2
- package/src/ai/fisherman/tools.ts +10 -4
- package/src/ai/navigator.ts +1 -4
- package/src/ai/pilot.ts +19 -18
- package/src/ai/planner/session-dedup.ts +16 -2
- package/src/ai/planner.ts +8 -4
- package/src/ai/provider.ts +18 -3
- package/src/ai/rules.ts +1 -0
- package/src/ai/tester.ts +1 -1
- package/src/ai/tools.ts +9 -2
- package/src/commands/config-command.ts +9 -2
- package/src/commands/init-command.ts +6 -27
- package/src/commands/sites-command.ts +6 -1
- package/src/config.ts +28 -25
- package/src/global-config.ts +81 -6
- package/src/utils/code-extractor.ts +6 -2
- package/src/utils/html.ts +6 -0
- package/src/utils/merge.ts +13 -0
package/src/ai/provider.ts
CHANGED
|
@@ -38,6 +38,11 @@ function createHarmonyChannelFallbackTool() {
|
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function withHarmonyChannelFallback(tools: any): any {
|
|
42
|
+
if (tools?.commentary) return tools;
|
|
43
|
+
return { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
let telemetryRegistered = false;
|
|
42
47
|
let beforeExitFlushHooked = false;
|
|
43
48
|
let activeOtelSdk: NodeSDK | null = null;
|
|
@@ -410,7 +415,7 @@ export class Provider {
|
|
|
410
415
|
promptLog(`Using model: ${modelName}`);
|
|
411
416
|
|
|
412
417
|
let toolsWithCommentary = tools;
|
|
413
|
-
if (
|
|
418
|
+
if (options.toolChoice !== 'required') toolsWithCommentary = withHarmonyChannelFallback(tools);
|
|
414
419
|
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
415
420
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
416
421
|
promptLog('Available tools:', toolNames);
|
|
@@ -420,9 +425,10 @@ export class Provider {
|
|
|
420
425
|
const extraStop = options.stopWhen;
|
|
421
426
|
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
422
427
|
if (extraStop) stopConditions.push(extraStop);
|
|
423
|
-
|
|
428
|
+
let config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
424
429
|
let attemptMessages = messages;
|
|
425
430
|
let invalidRequestFeedbackAdded = false;
|
|
431
|
+
let requiredToolChoiceRelaxed = false;
|
|
426
432
|
const executedStepMessages: ModelMessage[] = [];
|
|
427
433
|
try {
|
|
428
434
|
let response = await this.withModelRequestSlot(() =>
|
|
@@ -442,6 +448,11 @@ export class Provider {
|
|
|
442
448
|
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
443
449
|
attemptMessages = amended;
|
|
444
450
|
}
|
|
451
|
+
if (!requiredToolChoiceRelaxed && isRequiredToolChoiceError(error)) {
|
|
452
|
+
requiredToolChoiceRelaxed = true;
|
|
453
|
+
config = { ...config, tools: withHarmonyChannelFallback(tools), toolChoice: 'auto' };
|
|
454
|
+
tag('warning').log('Provider rejected required tool choice — retrying with automatic tool choice and channel fallback');
|
|
455
|
+
}
|
|
445
456
|
throw error;
|
|
446
457
|
})) as any;
|
|
447
458
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
@@ -470,7 +481,7 @@ export class Provider {
|
|
|
470
481
|
return response;
|
|
471
482
|
} catch (error: any) {
|
|
472
483
|
clearActivity();
|
|
473
|
-
if (error
|
|
484
|
+
if (isRequiredToolChoiceError(error)) {
|
|
474
485
|
return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
|
|
475
486
|
}
|
|
476
487
|
if (error?.name === 'AbortError') throw error;
|
|
@@ -734,6 +745,10 @@ function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): M
|
|
|
734
745
|
];
|
|
735
746
|
}
|
|
736
747
|
|
|
748
|
+
function isRequiredToolChoiceError(error: unknown): boolean {
|
|
749
|
+
return error instanceof Error && error.message.includes('Tool choice is required');
|
|
750
|
+
}
|
|
751
|
+
|
|
737
752
|
function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
738
753
|
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
739
754
|
if (markerIndex <= 0) return null;
|
package/src/ai/rules.ts
CHANGED
|
@@ -188,6 +188,7 @@ export const capabilityGroundingRule = dedent`
|
|
|
188
188
|
When a scenario depends on a named action, menu item, status, option, workflow, or feature,
|
|
189
189
|
that capability must be visible or explicitly confirmed in the current research/page context
|
|
190
190
|
for the same target entity type.
|
|
191
|
+
Ground on the scenario's outcome, not a planned step's control label — a missing label is not a missing capability.
|
|
191
192
|
|
|
192
193
|
Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
|
|
193
194
|
Do not replace a requested action with a synonym or related action unless the UI explicitly
|
package/src/ai/tester.ts
CHANGED
|
@@ -41,7 +41,7 @@ const SAMPLE_FILES: Record<string, string> = {
|
|
|
41
41
|
|
|
42
42
|
export class Tester extends TaskAgent implements Agent {
|
|
43
43
|
protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
44
|
-
protected readonly DELEGATED_ACTION_TOOLS = ['interact'];
|
|
44
|
+
protected readonly DELEGATED_ACTION_TOOLS = ['interact', 'visualClick'];
|
|
45
45
|
protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
|
|
46
46
|
emoji = '🧪';
|
|
47
47
|
private requestStore: RequestStore;
|
package/src/ai/tools.ts
CHANGED
|
@@ -375,6 +375,7 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
375
375
|
- Performing multiple form actions in a single batch
|
|
376
376
|
- Complex interactions requiring sequential commands
|
|
377
377
|
- Reaching items further down a list (I.scrollTo)
|
|
378
|
+
- Reloading the page to prove a change outlived it (I.reloadPage)
|
|
378
379
|
|
|
379
380
|
Example - filling a form with context (PREFERRED):
|
|
380
381
|
I.fillField('Username', 'John', '.login-form')
|
|
@@ -387,7 +388,7 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
387
388
|
I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
|
|
388
389
|
|
|
389
390
|
Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
|
|
390
|
-
Do not use: wait functions, amOnPage,
|
|
391
|
+
Do not use: wait functions, amOnPage, saveScreenshot
|
|
391
392
|
`,
|
|
392
393
|
inputSchema: z.object({
|
|
393
394
|
codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
|
|
@@ -1367,16 +1368,22 @@ async function extractWebElements(error: Error | null | undefined): Promise<Matc
|
|
|
1367
1368
|
|
|
1368
1369
|
function formatElementList(matched: MatchedElement[] | null): string {
|
|
1369
1370
|
if (!matched) return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1370
|
-
|
|
1371
|
+
const keys = matched.map((el) => `${el.text}::${el.visible}::${el.html}`);
|
|
1372
|
+
const list = matched
|
|
1371
1373
|
.map((el, i) => {
|
|
1372
1374
|
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1373
1375
|
if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
|
|
1374
1376
|
const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
|
|
1375
1377
|
if (wrapped.length) lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
|
|
1378
|
+
const same = keys.map((_, j) => j).filter((j) => j !== i && keys[j] === keys[i]);
|
|
1379
|
+
if (same.length) lines.push(`Identical to element ${same.map((j) => j + 1).join(', ')}`);
|
|
1376
1380
|
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1377
1381
|
return lines.join('\n');
|
|
1378
1382
|
})
|
|
1379
1383
|
.join('\n\n');
|
|
1384
|
+
|
|
1385
|
+
if (new Set(keys).size === matched.length) return list;
|
|
1386
|
+
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().`;
|
|
1380
1387
|
}
|
|
1381
1388
|
|
|
1382
1389
|
export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
|
|
@@ -15,7 +15,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
15
15
|
|
|
16
16
|
async execute(): Promise<void> {
|
|
17
17
|
const parser = ConfigParser.getInstance();
|
|
18
|
-
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
|
|
18
|
+
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot() }));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
static async summary(options: { config?: string; path?: string; url?: string; json?: boolean } = {}): Promise<string> {
|
|
@@ -28,13 +28,16 @@ export class ConfigCommand extends BaseCommand {
|
|
|
28
28
|
return load(site.url);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
31
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
static data(config: SummarizedConfig, options: ConfigSummaryOptions = {}): ConfigData {
|
|
35
35
|
let configPath = '';
|
|
36
36
|
if (options.configPath && existsSync(options.configPath)) configPath = options.configPath;
|
|
37
37
|
|
|
38
|
+
let siteConfigPath = '';
|
|
39
|
+
if (options.siteConfigPath && existsSync(options.siteConfigPath)) siteConfigPath = options.siteConfigPath;
|
|
40
|
+
|
|
38
41
|
const dirs: Record<string, string> = {};
|
|
39
42
|
if (options.root) {
|
|
40
43
|
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
@@ -60,6 +63,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
60
63
|
|
|
61
64
|
return {
|
|
62
65
|
config: configPath,
|
|
66
|
+
siteConfig: siteConfigPath,
|
|
63
67
|
url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
|
|
64
68
|
browser: config.playwright?.browser || '',
|
|
65
69
|
headless: !config.playwright?.show,
|
|
@@ -82,6 +86,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
82
86
|
const section = (title: string, entries: [string, string][]) => lines.push(...renderSection(title, entries));
|
|
83
87
|
|
|
84
88
|
const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
89
|
+
if (data.siteConfig) general.push(['site config', data.siteConfig]);
|
|
85
90
|
if (data.url) general.push(['url', data.url]);
|
|
86
91
|
if (data.browser) {
|
|
87
92
|
let window = 'visible';
|
|
@@ -118,12 +123,14 @@ export class ConfigCommand extends BaseCommand {
|
|
|
118
123
|
|
|
119
124
|
interface ConfigSummaryOptions {
|
|
120
125
|
configPath?: string | null;
|
|
126
|
+
siteConfigPath?: string | null;
|
|
121
127
|
root?: string;
|
|
122
128
|
json?: boolean;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
export interface ConfigData {
|
|
126
132
|
config: string;
|
|
133
|
+
siteConfig: string;
|
|
127
134
|
url: string;
|
|
128
135
|
browser: string;
|
|
129
136
|
headless: boolean;
|
|
@@ -7,10 +7,7 @@ import { getCliName } from '../utils/cli-name.ts';
|
|
|
7
7
|
import { log, tag } from '../utils/logger.js';
|
|
8
8
|
import { relativeToCwd } from '../utils/next-steps.ts';
|
|
9
9
|
|
|
10
|
-
function defaultConfigTemplate(provider: string
|
|
11
|
-
let moduleExport = 'module.exports = config;';
|
|
12
|
-
if (esm) moduleExport = 'export default config;';
|
|
13
|
-
|
|
10
|
+
function defaultConfigTemplate(provider: string): string {
|
|
14
11
|
return `// 'provider/model-id' uses a bundled provider.
|
|
15
12
|
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
16
13
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
@@ -35,7 +32,7 @@ ${modelLines(provider)}
|
|
|
35
32
|
},
|
|
36
33
|
};
|
|
37
34
|
|
|
38
|
-
|
|
35
|
+
export default config;
|
|
39
36
|
`;
|
|
40
37
|
}
|
|
41
38
|
|
|
@@ -144,8 +141,7 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
144
141
|
process.exit(1);
|
|
145
142
|
}
|
|
146
143
|
|
|
147
|
-
|
|
148
|
-
writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
|
|
144
|
+
writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
|
|
149
145
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
150
146
|
|
|
151
147
|
const envPath = resolve(process.cwd(), '.env');
|
|
@@ -271,6 +267,8 @@ function globalConfigTemplate(provider: string): string {
|
|
|
271
267
|
const { envKey } = PROVIDERS[provider];
|
|
272
268
|
|
|
273
269
|
return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
|
|
270
|
+
// Settings shared by every site. Each site extends them in
|
|
271
|
+
// ~/.explorbot/sites/<host>/explorbot.config.js, written on its first run.
|
|
274
272
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
275
273
|
// The key is read from ${envKey} in ~/.explorbot/.env
|
|
276
274
|
// Model ids are snapshotted from the recommendations of this Explorbot version.
|
|
@@ -288,29 +286,10 @@ ${modelLines(provider)}
|
|
|
288
286
|
},
|
|
289
287
|
};
|
|
290
288
|
|
|
291
|
-
|
|
289
|
+
export default config;
|
|
292
290
|
`;
|
|
293
291
|
}
|
|
294
292
|
|
|
295
|
-
function isModuleProject(configDir: string): boolean {
|
|
296
|
-
let currentDir = resolve(configDir);
|
|
297
|
-
|
|
298
|
-
while (true) {
|
|
299
|
-
const packagePath = join(currentDir, 'package.json');
|
|
300
|
-
if (existsSync(packagePath)) {
|
|
301
|
-
try {
|
|
302
|
-
return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
|
|
303
|
-
} catch {
|
|
304
|
-
return false;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const parentDir = dirname(currentDir);
|
|
309
|
-
if (parentDir === currentDir) return false;
|
|
310
|
-
currentDir = parentDir;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
293
|
function writeEnvKey(key: string, value: string): void {
|
|
315
294
|
const envPath = globalEnvPath();
|
|
316
295
|
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';
|
|
@@ -20,6 +20,11 @@ export class SitesCommand extends BaseCommand {
|
|
|
20
20
|
tag('info').log(`Registered sites (${sites.length}):`);
|
|
21
21
|
for (const site of sites) {
|
|
22
22
|
tag('info').log(` ${site.folder.padEnd(width)} ${site.url} last run ${site.lastRunAt.slice(0, 16).replace('T', ' ')}`);
|
|
23
|
+
|
|
24
|
+
let config = 'inherits global config';
|
|
25
|
+
const sitePath = findSiteConfig(site.dir);
|
|
26
|
+
if (sitePath) config = sitePath;
|
|
27
|
+
tag('info').log(` ${' '.repeat(width)} ${config}`);
|
|
23
28
|
}
|
|
24
29
|
tag('info').log('');
|
|
25
30
|
tag('info').log(`Stored in ${sitesDir()}`);
|
package/src/config.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import { parseEnv } from 'node:util';
|
|
6
6
|
import dedent from 'dedent';
|
|
7
7
|
import matter from 'gray-matter';
|
|
8
|
-
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
|
|
8
|
+
import { EXPLORBOT_CONFIG_PATHS, type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from './global-config.js';
|
|
9
9
|
import { getCliName } from './utils/cli-name.js';
|
|
10
|
+
import { deepMerge } from './utils/merge.js';
|
|
10
11
|
import { log, tag } from './utils/logger.js';
|
|
11
12
|
|
|
12
13
|
export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
@@ -268,7 +269,7 @@ const config: ExplorbotConfig = {
|
|
|
268
269
|
|
|
269
270
|
type RuleEntry = string | Record<string, string>;
|
|
270
271
|
|
|
271
|
-
export
|
|
272
|
+
export { EXPLORBOT_CONFIG_PATHS };
|
|
272
273
|
|
|
273
274
|
export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
274
275
|
{ name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
|
|
@@ -323,6 +324,7 @@ export class ConfigParser {
|
|
|
323
324
|
private runtimeTarget: string | null = null;
|
|
324
325
|
private site: SiteRecord | null = null;
|
|
325
326
|
private siteStartPath = '/';
|
|
327
|
+
private siteConfigPath: string | null = null;
|
|
326
328
|
|
|
327
329
|
private constructor() {}
|
|
328
330
|
|
|
@@ -407,16 +409,18 @@ export class ConfigParser {
|
|
|
407
409
|
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
408
410
|
}
|
|
409
411
|
|
|
410
|
-
|
|
411
|
-
await resolveConfigModels(
|
|
412
|
-
this.runtimeTarget = target;
|
|
413
|
-
this.configPath = sourcePath;
|
|
412
|
+
let config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
|
|
413
|
+
await resolveConfigModels(config.ai);
|
|
414
414
|
this.site = null;
|
|
415
|
+
this.siteConfigPath = null;
|
|
415
416
|
|
|
416
417
|
if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
|
|
417
|
-
this.enterGlobalMode(
|
|
418
|
+
config = await this.enterGlobalMode(config, target);
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
this.config = config;
|
|
422
|
+
this.runtimeTarget = target;
|
|
423
|
+
this.configPath = sourcePath;
|
|
420
424
|
this.applyEnvSpec(this.config);
|
|
421
425
|
|
|
422
426
|
// Restore original directory after successful config load
|
|
@@ -473,6 +477,10 @@ export class ConfigParser {
|
|
|
473
477
|
return this.site;
|
|
474
478
|
}
|
|
475
479
|
|
|
480
|
+
public getSiteConfigPath(): string | null {
|
|
481
|
+
return this.siteConfigPath;
|
|
482
|
+
}
|
|
483
|
+
|
|
476
484
|
public resolveTargetPath(target?: string): string {
|
|
477
485
|
if (!this.site) {
|
|
478
486
|
const configured = this.config?.playwright?.url || this.config?.web?.url;
|
|
@@ -512,6 +520,7 @@ export class ConfigParser {
|
|
|
512
520
|
ConfigParser.instance.runtimeTarget = null;
|
|
513
521
|
ConfigParser.instance.site = null;
|
|
514
522
|
ConfigParser.instance.siteStartPath = '/';
|
|
523
|
+
ConfigParser.instance.siteConfigPath = null;
|
|
515
524
|
}
|
|
516
525
|
}
|
|
517
526
|
|
|
@@ -563,16 +572,24 @@ export class ConfigParser {
|
|
|
563
572
|
}
|
|
564
573
|
}
|
|
565
574
|
|
|
566
|
-
private enterGlobalMode(config: ExplorbotConfig, target: string | null):
|
|
575
|
+
private async enterGlobalMode(config: ExplorbotConfig, target: string | null): Promise<ExplorbotConfig> {
|
|
567
576
|
const site = resolveSiteTarget(target || undefined, config.web?.url || config.playwright?.url);
|
|
568
577
|
this.site = registerSite(site.baseUrl);
|
|
569
578
|
this.siteStartPath = site.path;
|
|
570
579
|
|
|
571
|
-
|
|
572
|
-
|
|
580
|
+
const { path: sitePath, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
|
|
581
|
+
this.siteConfigPath = sitePath;
|
|
582
|
+
|
|
583
|
+
const merged = deepMerge(config, siteConfig);
|
|
584
|
+
await resolveConfigModels(merged.ai);
|
|
585
|
+
resolveLangfuse(merged.ai);
|
|
586
|
+
|
|
587
|
+
merged.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
588
|
+
merged.playwright = { ...merged.playwright, browser: merged.playwright?.browser || 'chromium', url: site.baseUrl };
|
|
573
589
|
materializeKnowledge(this.site.dir);
|
|
574
590
|
|
|
575
591
|
log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
|
|
592
|
+
return merged;
|
|
576
593
|
}
|
|
577
594
|
|
|
578
595
|
private applyEnvSpec(config: ExplorbotConfig): void {
|
|
@@ -720,21 +737,7 @@ export class ConfigParser {
|
|
|
720
737
|
},
|
|
721
738
|
};
|
|
722
739
|
|
|
723
|
-
return
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
private deepMerge(target: any, source: any): any {
|
|
727
|
-
const result = { ...target };
|
|
728
|
-
|
|
729
|
-
for (const key in source) {
|
|
730
|
-
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
|
|
731
|
-
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
|
732
|
-
} else {
|
|
733
|
-
result[key] = source[key];
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
return result;
|
|
740
|
+
return deepMerge(defaults, config);
|
|
738
741
|
}
|
|
739
742
|
|
|
740
743
|
public ensureDirectory(path: string): void {
|
package/src/global-config.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import dedent from 'dedent';
|
|
4
6
|
|
|
5
7
|
const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
|
|
6
8
|
const SITE_DIRS = ['knowledge', 'experience', 'output'];
|
|
7
9
|
|
|
10
|
+
export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
|
|
11
|
+
|
|
8
12
|
export function globalDir(): string {
|
|
9
13
|
return join(os.homedir(), '.explorbot');
|
|
10
14
|
}
|
|
@@ -18,11 +22,7 @@ export function globalConfigPath(): string {
|
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function findGlobalConfig(): string | null {
|
|
21
|
-
|
|
22
|
-
const fullPath = join(globalDir(), name);
|
|
23
|
-
if (existsSync(fullPath)) return fullPath;
|
|
24
|
-
}
|
|
25
|
-
return null;
|
|
25
|
+
return firstExisting(globalDir(), GLOBAL_CONFIG_NAMES);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function isGlobalConfigPath(configPath: string): boolean {
|
|
@@ -47,6 +47,52 @@ export function listSites(): SiteRecord[] {
|
|
|
47
47
|
.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export function findSiteConfig(dir: string): string | null {
|
|
51
|
+
return firstExisting(dir, EXPLORBOT_CONFIG_PATHS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureSiteConfig(dir: string, baseUrl: string): string {
|
|
55
|
+
const existing = findSiteConfig(dir);
|
|
56
|
+
if (existing) return existing;
|
|
57
|
+
|
|
58
|
+
const path = join(dir, EXPLORBOT_CONFIG_PATHS[0]);
|
|
59
|
+
writeFileSync(path, siteConfigTemplate(baseUrl), 'utf8');
|
|
60
|
+
return path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadSiteConfig(dir: string, baseUrl: string): Promise<{ path: string; config: any }> {
|
|
64
|
+
const path = ensureSiteConfig(dir, baseUrl);
|
|
65
|
+
const module = await import(pathToFileURL(resolve(path)).href);
|
|
66
|
+
const config = module.default || module;
|
|
67
|
+
validateSiteConfig(config, path, baseUrl);
|
|
68
|
+
return { path, config };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateSiteConfig(config: any, configPath: string, baseUrl: string): void {
|
|
72
|
+
const url = config?.web?.url;
|
|
73
|
+
if (!url) {
|
|
74
|
+
throw new Error(dedent`
|
|
75
|
+
Site config is missing web.url.
|
|
76
|
+
${configPath}
|
|
77
|
+
|
|
78
|
+
Add it so the config states which site it configures:
|
|
79
|
+
web: { url: '${baseUrl}' },
|
|
80
|
+
`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const declared = URL.parse(url)?.origin;
|
|
84
|
+
if (declared === baseUrl) return;
|
|
85
|
+
|
|
86
|
+
throw new Error(dedent`
|
|
87
|
+
Site config declares a different site.
|
|
88
|
+
${configPath}
|
|
89
|
+
web.url: ${url}
|
|
90
|
+
site: ${baseUrl}
|
|
91
|
+
|
|
92
|
+
Fix web.url, or explore ${url} to register it as its own site.
|
|
93
|
+
`);
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
export function findSiteWith(subpath: string): SiteRecord | undefined {
|
|
51
97
|
return listSites().find((site) => existsSync(join(site.dir, subpath)));
|
|
52
98
|
}
|
|
@@ -107,6 +153,35 @@ export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): Sit
|
|
|
107
153
|
return { baseUrl: site.url, path };
|
|
108
154
|
}
|
|
109
155
|
|
|
156
|
+
function firstExisting(dir: string, names: string[]): string | null {
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
const fullPath = join(dir, name);
|
|
159
|
+
if (existsSync(fullPath)) return fullPath;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function siteConfigTemplate(baseUrl: string): string {
|
|
165
|
+
return `// Config for ${baseUrl}
|
|
166
|
+
// Extends ~/.explorbot/config.js — set only what differs.
|
|
167
|
+
const config = {
|
|
168
|
+
web: {
|
|
169
|
+
url: '${baseUrl}',
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// ai: {
|
|
173
|
+
// model: 'openrouter/openai/gpt-oss-120b',
|
|
174
|
+
// },
|
|
175
|
+
|
|
176
|
+
// playwright: {
|
|
177
|
+
// show: true,
|
|
178
|
+
// },
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export default config;
|
|
182
|
+
`;
|
|
183
|
+
}
|
|
184
|
+
|
|
110
185
|
function readSite(folder: string): SiteRecord | null {
|
|
111
186
|
const dir = join(sitesDir(), folder);
|
|
112
187
|
const metaPath = join(dir, 'site.json');
|
|
@@ -2,13 +2,17 @@ import { createDebug } from './logger.js';
|
|
|
2
2
|
|
|
3
3
|
const debugLog = createDebug('explorbot:code-extractor');
|
|
4
4
|
|
|
5
|
+
const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
|
|
6
|
+
|
|
5
7
|
export function extractCodeBlocks(aiResponse: string): string[] {
|
|
6
|
-
const codeBlockRegex = /```(
|
|
8
|
+
const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
|
|
7
9
|
const codeBlocks: string[] = [];
|
|
8
10
|
let match: RegExpExecArray | null = null;
|
|
9
11
|
|
|
10
12
|
while ((match = codeBlockRegex.exec(aiResponse))) {
|
|
11
|
-
const
|
|
13
|
+
const language = match[1].trim().toLowerCase();
|
|
14
|
+
if (!JS_LANGUAGES.has(language)) continue;
|
|
15
|
+
const code = match[2].trim();
|
|
12
16
|
if (!code) continue;
|
|
13
17
|
try {
|
|
14
18
|
new Function('I', code);
|
package/src/utils/html.ts
CHANGED
|
@@ -1420,6 +1420,12 @@ function cleanElement(element: parse5TreeAdapter.Element): void {
|
|
|
1420
1420
|
'aria-labelledby',
|
|
1421
1421
|
'aria-describedby',
|
|
1422
1422
|
'aria-owns',
|
|
1423
|
+
'aria-checked',
|
|
1424
|
+
'aria-expanded',
|
|
1425
|
+
'aria-selected',
|
|
1426
|
+
'aria-pressed',
|
|
1427
|
+
'aria-current',
|
|
1428
|
+
'aria-disabled',
|
|
1423
1429
|
'role',
|
|
1424
1430
|
'title',
|
|
1425
1431
|
'href',
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function deepMerge(target: any, source: any): any {
|
|
2
|
+
const result = { ...target };
|
|
3
|
+
|
|
4
|
+
for (const key in source) {
|
|
5
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
|
|
6
|
+
result[key] = deepMerge(result[key] || {}, source[key]);
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
result[key] = source[key];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return result;
|
|
13
|
+
}
|