explorbot 0.2.2 → 0.2.3
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/README.md +1 -1
- package/bin/explorbot-cli.ts +52 -37
- package/boat/api-tester/src/apibot.ts +4 -2
- package/boat/api-tester/src/cli.ts +2 -2
- package/boat/api-tester/src/config.ts +39 -8
- package/boat/doc-collector/src/cli.ts +1 -0
- package/boat/doc-collector/src/docs-renderer.ts +18 -4
- package/boat/doc-collector/src/state-diagram.ts +61 -14
- package/boat/prima/bin/prima-cli.ts +5 -0
- package/boat/prima/package.json +16 -0
- package/boat/prima/src/cli.ts +222 -0
- package/boat/prima/src/envelope.ts +141 -0
- package/boat/prima/src/prima.ts +705 -0
- package/boat/prima/src/pw-parser.ts +17 -0
- package/boat/prima/src/pw-registry.ts +75 -0
- package/dist/bin/explorbot-cli.js +44 -31
- package/dist/boat/api-tester/src/apibot.js +3 -2
- package/dist/boat/api-tester/src/cli.js +2 -2
- package/dist/boat/api-tester/src/config.js +36 -8
- package/dist/boat/doc-collector/src/cli.js +1 -0
- package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
- package/dist/boat/doc-collector/src/state-diagram.js +57 -13
- package/dist/boat/prima/bin/prima-cli.js +4 -0
- package/dist/boat/prima/src/cli.js +200 -0
- package/dist/boat/prima/src/envelope.js +116 -0
- package/dist/boat/prima/src/prima.js +635 -0
- package/dist/boat/prima/src/pw-parser.js +18 -0
- package/dist/boat/prima/src/pw-registry.js +66 -0
- package/dist/models.json +3 -0
- package/dist/package.json +6 -2
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +5 -5
- package/dist/src/ai/captain/mixin.js +3 -4
- package/dist/src/ai/captain/web-mode.js +1 -1
- package/dist/src/ai/navigator.d.ts +4 -0
- package/dist/src/ai/navigator.js +11 -6
- package/dist/src/ai/planner.d.ts +1 -0
- package/dist/src/ai/planner.js +6 -0
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/task-agent.js +1 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +13 -0
- package/dist/src/application-spec-contract.d.ts +8 -0
- package/dist/src/application-spec-contract.js +8 -0
- package/dist/src/application-spec.d.ts +15 -0
- package/dist/src/application-spec.js +71 -0
- package/dist/src/browser-server.d.ts +12 -6
- package/dist/src/browser-server.js +74 -19
- package/dist/src/commands/clean-command.js +2 -7
- package/dist/src/commands/init-command.d.ts +5 -0
- package/dist/src/commands/init-command.js +119 -1
- package/dist/src/commands/navigate-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/sites-command.d.ts +6 -0
- package/dist/src/commands/sites-command.js +23 -0
- package/dist/src/components/InitWizard.d.ts +10 -0
- package/dist/src/components/InitWizard.js +133 -0
- package/dist/src/components/InputReadline.d.ts +1 -0
- package/dist/src/components/InputReadline.js +7 -4
- package/dist/src/config.d.ts +24 -5
- package/dist/src/config.js +146 -37
- package/dist/src/explorbot.d.ts +9 -0
- package/dist/src/explorbot.js +24 -5
- package/dist/src/explorer.d.ts +4 -1
- package/dist/src/explorer.js +40 -6
- package/dist/src/global-config.d.ts +22 -0
- package/dist/src/global-config.js +117 -0
- package/dist/src/knowledge-tracker.d.ts +5 -1
- package/dist/src/knowledge-tracker.js +14 -1
- package/dist/src/utils/cli-name.js +6 -2
- package/dist/src/utils/test-files.js +1 -2
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +9 -0
- package/models.json +3 -0
- package/package.json +6 -2
- package/src/action.ts +9 -5
- package/src/ai/captain/mixin.ts +3 -3
- package/src/ai/captain/web-mode.ts +1 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner.ts +7 -0
- package/src/ai/researcher.ts +1 -1
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +15 -0
- package/src/application-spec-contract.ts +10 -0
- package/src/application-spec.ts +87 -0
- package/src/browser-server.ts +74 -19
- package/src/commands/clean-command.ts +1 -6
- package/src/commands/init-command.ts +146 -1
- package/src/commands/navigate-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/sites-command.ts +27 -0
- package/src/components/InitWizard.tsx +166 -0
- package/src/components/InputReadline.tsx +8 -4
- package/src/config.ts +162 -39
- package/src/explorbot.ts +30 -5
- package/src/explorer.ts +45 -7
- package/src/global-config.ts +148 -0
- package/src/knowledge-tracker.ts +17 -1
- package/src/utils/cli-name.ts +5 -2
- package/src/utils/test-files.ts +1 -2
- package/src/utils/url-matcher.ts +10 -0
package/src/config.ts
CHANGED
|
@@ -2,17 +2,20 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync,
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import path, { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { parseEnv } from 'node:util';
|
|
5
|
+
import dedent from 'dedent';
|
|
5
6
|
import matter from 'gray-matter';
|
|
7
|
+
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
|
|
8
|
+
import { getCliName } from './utils/cli-name.js';
|
|
6
9
|
import { log } from './utils/logger.js';
|
|
7
10
|
|
|
8
|
-
export const PROVIDERS: Record<string,
|
|
9
|
-
openai: async () => (await import('@ai-sdk/openai')).createOpenAI(),
|
|
10
|
-
anthropic: async () => (await import('@ai-sdk/anthropic')).createAnthropic(),
|
|
11
|
-
google: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI(),
|
|
12
|
-
groq: async () => (await import('@ai-sdk/groq')).createGroq(),
|
|
13
|
-
mistral: async () => (await import('@ai-sdk/mistral')).createMistral(),
|
|
14
|
-
openrouter: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter(),
|
|
15
|
-
sambanova: async () => (await import('sambanova-ai-provider')).createSambaNova(),
|
|
11
|
+
export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
12
|
+
openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
13
|
+
anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
14
|
+
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
|
|
15
|
+
groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
|
|
16
|
+
mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
|
|
17
|
+
openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
18
|
+
sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
let cachedOutputRoot: string | null = null;
|
|
@@ -227,6 +230,7 @@ interface ExplorbotConfig {
|
|
|
227
230
|
knowledge: string;
|
|
228
231
|
experience: string;
|
|
229
232
|
output: string;
|
|
233
|
+
spec?: string;
|
|
230
234
|
};
|
|
231
235
|
experience?: {
|
|
232
236
|
maxReadLines?: number;
|
|
@@ -260,7 +264,8 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
|
260
264
|
{ name: 'EXPLORBOT_URL', required: true, description: 'Base URL to test; the API boat reads it as the base endpoint' },
|
|
261
265
|
{ name: 'EXPLORBOT_VISION_MODEL', description: 'Screenshot analysis; overrides the provider recommendation' },
|
|
262
266
|
{ name: 'EXPLORBOT_AGENTIC_MODEL', description: 'Captain and Pilot decisions; overrides the provider recommendation' },
|
|
263
|
-
{ name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to
|
|
267
|
+
{ name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites' },
|
|
268
|
+
{ name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
|
|
264
269
|
{ name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
|
|
265
270
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
266
271
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
@@ -299,14 +304,26 @@ export class ConfigParser {
|
|
|
299
304
|
private static recommended: Record<string, Record<string, string>> | null = null;
|
|
300
305
|
private config: ExplorbotConfig | null = null;
|
|
301
306
|
private configPath: string | null = null;
|
|
302
|
-
private
|
|
307
|
+
private runtimeTarget: string | null = null;
|
|
308
|
+
private site: SiteRecord | null = null;
|
|
309
|
+
private siteStartPath = '/';
|
|
303
310
|
|
|
304
311
|
private constructor() {}
|
|
305
312
|
|
|
306
|
-
public static loadEnv(filePath: string): void {
|
|
313
|
+
public static loadEnv(filePath: string, keepExisting = false): void {
|
|
307
314
|
const resolved = resolve(filePath);
|
|
308
315
|
if (!existsSync(resolved)) return;
|
|
309
|
-
|
|
316
|
+
|
|
317
|
+
const parsed = parseEnv(readFileSync(resolved, 'utf8'));
|
|
318
|
+
if (!keepExisting) {
|
|
319
|
+
Object.assign(process.env, parsed);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
324
|
+
if (key in process.env) continue;
|
|
325
|
+
process.env[key] = value as string;
|
|
326
|
+
}
|
|
310
327
|
}
|
|
311
328
|
|
|
312
329
|
public static recommendedModels(): Record<string, Record<string, string>> {
|
|
@@ -325,8 +342,10 @@ export class ConfigParser {
|
|
|
325
342
|
config?: string;
|
|
326
343
|
path?: string;
|
|
327
344
|
baseUrl?: string;
|
|
345
|
+
from?: string;
|
|
328
346
|
}): Promise<ExplorbotConfig> {
|
|
329
|
-
|
|
347
|
+
const target = options?.baseUrl || options?.from || null;
|
|
348
|
+
if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
|
|
330
349
|
return this.config;
|
|
331
350
|
}
|
|
332
351
|
|
|
@@ -342,6 +361,7 @@ export class ConfigParser {
|
|
|
342
361
|
}
|
|
343
362
|
|
|
344
363
|
ConfigParser.loadEnv('.env');
|
|
364
|
+
ConfigParser.loadEnv(globalEnvPath(), true);
|
|
345
365
|
|
|
346
366
|
try {
|
|
347
367
|
const resolvedPath = options?.config || this.findConfigFile();
|
|
@@ -361,16 +381,25 @@ export class ConfigParser {
|
|
|
361
381
|
}
|
|
362
382
|
|
|
363
383
|
if (!resolvedPath) {
|
|
364
|
-
|
|
365
|
-
|
|
384
|
+
let envUrl = options?.baseUrl;
|
|
385
|
+
if (!envUrl && target?.startsWith('http')) envUrl = target;
|
|
386
|
+
|
|
387
|
+
const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
|
|
388
|
+
loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
|
|
366
389
|
sourcePath = join(outputRoot, 'explorbot.config.js');
|
|
367
390
|
|
|
368
391
|
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
369
392
|
}
|
|
370
393
|
|
|
371
394
|
this.config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
|
|
372
|
-
this.
|
|
395
|
+
await resolveConfigModels(this.config.ai);
|
|
396
|
+
this.runtimeTarget = target;
|
|
373
397
|
this.configPath = sourcePath;
|
|
398
|
+
this.site = null;
|
|
399
|
+
|
|
400
|
+
if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
|
|
401
|
+
this.enterGlobalMode(this.config, target);
|
|
402
|
+
}
|
|
374
403
|
|
|
375
404
|
// Restore original directory after successful config load
|
|
376
405
|
if (options?.path && originalCwd !== process.cwd()) {
|
|
@@ -383,6 +412,7 @@ export class ConfigParser {
|
|
|
383
412
|
if (options?.path && originalCwd !== process.cwd()) {
|
|
384
413
|
process.chdir(originalCwd);
|
|
385
414
|
}
|
|
415
|
+
if (error instanceof ConfigMissingError) throw error;
|
|
386
416
|
throw new Error(`Failed to load configuration: ${error}`);
|
|
387
417
|
}
|
|
388
418
|
}
|
|
@@ -400,21 +430,37 @@ export class ConfigParser {
|
|
|
400
430
|
|
|
401
431
|
public getOutputDir(): string {
|
|
402
432
|
const config = this.getConfig();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return path.join(path.dirname(configPath), config.dirs?.output || 'output');
|
|
433
|
+
if (!this.configPath) throw new Error('Config path not found');
|
|
434
|
+
return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
|
|
406
435
|
}
|
|
407
436
|
|
|
408
437
|
public getProjectRoot(): string {
|
|
438
|
+
if (this.site) return this.site.dir;
|
|
409
439
|
const configPath = this.getConfigPath();
|
|
410
440
|
if (configPath) return path.dirname(configPath);
|
|
411
441
|
return process.cwd();
|
|
412
442
|
}
|
|
413
443
|
|
|
414
444
|
public resolveProjectDir(relativeDir: string): string {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
445
|
+
if (!this.configPath) return relativeDir;
|
|
446
|
+
return path.join(this.getProjectRoot(), relativeDir);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
public isGlobalMode(): boolean {
|
|
450
|
+
return !!this.site;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
public getSite(): SiteRecord | null {
|
|
454
|
+
return this.site;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
public resolveTargetPath(target?: string): string {
|
|
458
|
+
if (!this.site) return target || '/';
|
|
459
|
+
if (!target) return this.siteStartPath;
|
|
460
|
+
|
|
461
|
+
const resolved = resolveSiteTarget(target, this.site.url);
|
|
462
|
+
if (resolved.baseUrl !== this.site.url) return target;
|
|
463
|
+
return resolved.path;
|
|
418
464
|
}
|
|
419
465
|
|
|
420
466
|
public getStatesDir(): string {
|
|
@@ -435,7 +481,9 @@ export class ConfigParser {
|
|
|
435
481
|
if (ConfigParser.instance) {
|
|
436
482
|
ConfigParser.instance.config = null;
|
|
437
483
|
ConfigParser.instance.configPath = null;
|
|
438
|
-
ConfigParser.instance.
|
|
484
|
+
ConfigParser.instance.runtimeTarget = null;
|
|
485
|
+
ConfigParser.instance.site = null;
|
|
486
|
+
ConfigParser.instance.siteStartPath = '/';
|
|
439
487
|
}
|
|
440
488
|
}
|
|
441
489
|
|
|
@@ -486,11 +534,22 @@ export class ConfigParser {
|
|
|
486
534
|
}
|
|
487
535
|
}
|
|
488
536
|
|
|
537
|
+
private enterGlobalMode(config: ExplorbotConfig, target: string | null): void {
|
|
538
|
+
const site = resolveSiteTarget(target || undefined, config.web?.url || config.playwright?.url);
|
|
539
|
+
this.site = registerSite(site.baseUrl);
|
|
540
|
+
this.siteStartPath = site.path;
|
|
541
|
+
|
|
542
|
+
config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
543
|
+
config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
|
|
544
|
+
|
|
545
|
+
log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
|
|
546
|
+
}
|
|
547
|
+
|
|
489
548
|
private async buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig> {
|
|
490
549
|
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
491
550
|
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
|
|
492
551
|
if (!provider && !modelSpec) {
|
|
493
|
-
throw new
|
|
552
|
+
throw new ConfigMissingError(missingConfigMessage());
|
|
494
553
|
}
|
|
495
554
|
if (modelSpec && !provider && !modelSpec.includes('/')) {
|
|
496
555
|
throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
|
|
@@ -524,11 +583,14 @@ export class ConfigParser {
|
|
|
524
583
|
if (agenticSpec) ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
|
|
525
584
|
if (!agenticSpec && recommended.agenticModel) ai.agenticModel = await resolveModel(provider!, 'agenticModel');
|
|
526
585
|
|
|
586
|
+
const dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
587
|
+
if (process.env.EXPLORBOT_OUTPUT) dirs.output = '.';
|
|
588
|
+
|
|
527
589
|
return {
|
|
528
590
|
playwright: { browser: 'chromium', url, show: false },
|
|
529
591
|
ai,
|
|
530
|
-
dirs
|
|
531
|
-
experience: { disabled:
|
|
592
|
+
dirs,
|
|
593
|
+
experience: { disabled: !!process.env.EXPLORBOT_EPHEMERAL },
|
|
532
594
|
};
|
|
533
595
|
}
|
|
534
596
|
|
|
@@ -542,7 +604,8 @@ export class ConfigParser {
|
|
|
542
604
|
}
|
|
543
605
|
}
|
|
544
606
|
|
|
545
|
-
return null;
|
|
607
|
+
if (envConfigRequested()) return null;
|
|
608
|
+
return findGlobalConfig();
|
|
546
609
|
}
|
|
547
610
|
|
|
548
611
|
private async loadConfigModule(configPath: string): Promise<any> {
|
|
@@ -664,30 +727,84 @@ export async function resolveModel(spec: string, role: ModelRole = 'model'): Pro
|
|
|
664
727
|
return createModel(spec, modelId);
|
|
665
728
|
}
|
|
666
729
|
|
|
667
|
-
export
|
|
730
|
+
export class ConfigMissingError extends Error {}
|
|
731
|
+
|
|
732
|
+
export function envConfigRequested(): boolean {
|
|
733
|
+
return !!(process.env.EXPLORBOT_AI_PROVIDER || process.env.EXPLORBOT_AI_MODEL);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function missingConfigMessage(configFile = 'explorbot.config.js'): string {
|
|
737
|
+
const cli = getCliName();
|
|
738
|
+
return dedent`
|
|
739
|
+
No AI configuration found. Set up explorbot in one of these ways:
|
|
740
|
+
|
|
741
|
+
Global - configure this machine once, then run from any directory:
|
|
742
|
+
${cli} init --global
|
|
743
|
+
|
|
744
|
+
Local - create ${configFile} for this project:
|
|
745
|
+
${cli} init
|
|
746
|
+
|
|
747
|
+
Environment - one-off run, no files written:
|
|
748
|
+
EXPLORBOT_AI_PROVIDER=openrouter EXPLORBOT_URL=https://your-app.example.com ${cli} ...
|
|
749
|
+
|
|
750
|
+
Providers: ${Object.keys(PROVIDERS).join(', ')}
|
|
751
|
+
`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
755
|
+
if (!ai) return;
|
|
756
|
+
|
|
757
|
+
const roles: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
|
|
758
|
+
for (const role of roles) {
|
|
759
|
+
if (typeof ai[role] === 'string') ai[role] = await resolveModel(ai[role], role);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
for (const agent of Object.values(ai.agents || {})) {
|
|
763
|
+
if (typeof agent?.model === 'string') agent.model = await resolveModel(agent.model);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function resolveOutputRoot(baseUrl?: string): string {
|
|
668
768
|
if (cachedOutputRoot) return cachedOutputRoot;
|
|
669
769
|
|
|
670
770
|
const configured = process.env.EXPLORBOT_OUTPUT;
|
|
671
|
-
if (
|
|
672
|
-
cachedOutputRoot =
|
|
771
|
+
if (configured) {
|
|
772
|
+
cachedOutputRoot = resolve(configured);
|
|
773
|
+
mkdirSync(cachedOutputRoot, { recursive: true });
|
|
673
774
|
return cachedOutputRoot;
|
|
674
775
|
}
|
|
675
776
|
|
|
676
|
-
|
|
677
|
-
|
|
777
|
+
if (baseUrl) {
|
|
778
|
+
cachedOutputRoot = resolveStateRoot(baseUrl, !!process.env.EXPLORBOT_EPHEMERAL);
|
|
779
|
+
return cachedOutputRoot;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
|
|
678
783
|
return cachedOutputRoot;
|
|
679
784
|
}
|
|
680
785
|
|
|
786
|
+
export function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string {
|
|
787
|
+
const url = URL.parse(baseUrl);
|
|
788
|
+
if (ephemeral || !url?.host) return mkdtempSync(join(tmpdir(), 'explorbot-'));
|
|
789
|
+
|
|
790
|
+
return registerSite(url.origin).dir;
|
|
791
|
+
}
|
|
792
|
+
|
|
681
793
|
export function materializeKnowledge(outputRoot: string): void {
|
|
682
794
|
const inline = process.env.EXPLORBOT_KNOWLEDGE;
|
|
683
795
|
const knowledgeFile = process.env.EXPLORBOT_KNOWLEDGE_FILE;
|
|
796
|
+
const knowledgeDir = join(outputRoot, 'knowledge');
|
|
797
|
+
const globalFile = join(knowledgeDir, 'global.md');
|
|
798
|
+
const envDir = join(knowledgeDir, 'env');
|
|
799
|
+
|
|
800
|
+
if (!inline) rmSync(globalFile, { force: true });
|
|
801
|
+
rmSync(envDir, { recursive: true, force: true });
|
|
684
802
|
if (!inline && !knowledgeFile) return;
|
|
685
803
|
|
|
686
|
-
const knowledgeDir = join(outputRoot, 'knowledge');
|
|
687
804
|
mkdirSync(knowledgeDir, { recursive: true });
|
|
688
805
|
|
|
689
806
|
if (inline) {
|
|
690
|
-
writeFileSync(
|
|
807
|
+
writeFileSync(globalFile, matter.stringify(inline, { url: '*', endpoint: '*' }));
|
|
691
808
|
}
|
|
692
809
|
|
|
693
810
|
if (!knowledgeFile) return;
|
|
@@ -696,23 +813,29 @@ export function materializeKnowledge(outputRoot: string): void {
|
|
|
696
813
|
if (!existsSync(source)) {
|
|
697
814
|
throw new Error(`Knowledge file from EXPLORBOT_KNOWLEDGE_FILE not found: ${source}`);
|
|
698
815
|
}
|
|
699
|
-
|
|
816
|
+
mkdirSync(envDir, { recursive: true });
|
|
817
|
+
copyFileSync(source, join(envDir, basename(source)));
|
|
700
818
|
}
|
|
701
819
|
|
|
702
820
|
export async function createModel(provider: string, modelId: string): Promise<any> {
|
|
703
|
-
const
|
|
704
|
-
if (!
|
|
821
|
+
const info = PROVIDERS[provider];
|
|
822
|
+
if (!info) {
|
|
705
823
|
throw new Error(`Unknown AI provider "${provider}". Supported providers: ${Object.keys(PROVIDERS).join(', ')}`);
|
|
706
824
|
}
|
|
707
|
-
return (await
|
|
825
|
+
return (await info.load())(modelId);
|
|
708
826
|
}
|
|
709
827
|
|
|
710
828
|
type ModelRole = 'model' | 'visionModel' | 'agenticModel';
|
|
711
829
|
|
|
830
|
+
interface ProviderInfo {
|
|
831
|
+
envKey: string;
|
|
832
|
+
load: () => Promise<(modelId: string) => any>;
|
|
833
|
+
}
|
|
834
|
+
|
|
712
835
|
interface EnvVar {
|
|
713
836
|
name: string;
|
|
714
837
|
description: string;
|
|
715
838
|
required?: boolean;
|
|
716
839
|
}
|
|
717
840
|
|
|
718
|
-
export type { ModelRole, EnvVar };
|
|
841
|
+
export type { ModelRole, EnvVar, ProviderInfo };
|
package/src/explorbot.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import type { Browser } from 'playwright';
|
|
3
4
|
import type { AgentDeps } from './ai/agent.ts';
|
|
4
5
|
import { Captain } from './ai/captain.ts';
|
|
5
6
|
import { Driller } from './ai/driller.ts';
|
|
@@ -46,6 +47,10 @@ export interface ExplorBotOptions {
|
|
|
46
47
|
headless?: boolean;
|
|
47
48
|
incognito?: boolean;
|
|
48
49
|
session?: string | boolean;
|
|
50
|
+
instance?: string;
|
|
51
|
+
optionalAi?: boolean;
|
|
52
|
+
attachedBrowser?: Browser;
|
|
53
|
+
applicationSpec?: string;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export type UserResolveFunction = (error?: Error, showWelcome?: boolean) => Promise<string | null>;
|
|
@@ -61,6 +66,7 @@ export class ExplorBot {
|
|
|
61
66
|
private planFeature?: string;
|
|
62
67
|
lastPlanError: Error | null = null;
|
|
63
68
|
lastSavedPlanPath: string | null = null;
|
|
69
|
+
private aiFailure: string | null = null;
|
|
64
70
|
private agents: Record<string, any> = {};
|
|
65
71
|
private sessionPlans: Plan[] = [];
|
|
66
72
|
private lastReportedTestCount = 0;
|
|
@@ -88,6 +94,10 @@ export class ExplorBot {
|
|
|
88
94
|
this.userResolveFn = fn;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
attachBrowser(browser: Browser): void {
|
|
98
|
+
this.options.attachedBrowser = browser;
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
async start(): Promise<void> {
|
|
92
102
|
if (this.explorer) {
|
|
93
103
|
return;
|
|
@@ -104,7 +114,7 @@ export class ExplorBot {
|
|
|
104
114
|
playwrightRecorder: this.playwrightRecorder(),
|
|
105
115
|
});
|
|
106
116
|
await this.explorer.start();
|
|
107
|
-
if (!this.options.incognito) {
|
|
117
|
+
if (!this.options.incognito && this.provider) {
|
|
108
118
|
await this.agentExperienceCompactor().autocompact();
|
|
109
119
|
}
|
|
110
120
|
} catch (error) {
|
|
@@ -118,22 +128,26 @@ export class ExplorBot {
|
|
|
118
128
|
if (this.provider) return;
|
|
119
129
|
this.config = await this.configParser.loadConfig(this.options);
|
|
120
130
|
if (this.options.session === true) this.options.session = path.join(this.configParser.getOutputDir(), 'session.json');
|
|
131
|
+
if (this.options.optionalAi) return this.bootstrapOptionalProvider();
|
|
121
132
|
this.provider = new AIProvider(this.config.ai);
|
|
122
133
|
await this.provider.validateConnection();
|
|
123
134
|
}
|
|
124
135
|
|
|
136
|
+
aiFailureReason(): string | null {
|
|
137
|
+
return this.aiFailure;
|
|
138
|
+
}
|
|
139
|
+
|
|
125
140
|
async stop(): Promise<void> {
|
|
126
141
|
this.agents.quartermaster?.stop();
|
|
127
142
|
await this.explorer?.stop();
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
async visitInitialState(): Promise<void> {
|
|
131
|
-
|
|
132
|
-
await this.visit(url);
|
|
146
|
+
await this.visit(this.options.from || '/');
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
async visit(url: string): Promise<void> {
|
|
136
|
-
return this.agentNavigator().visit(url);
|
|
150
|
+
return this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
async openTab(): Promise<void> {
|
|
@@ -149,7 +163,7 @@ export class ExplorBot {
|
|
|
149
163
|
}
|
|
150
164
|
|
|
151
165
|
knowledgeTracker(): KnowledgeTracker {
|
|
152
|
-
return (this._knowledgeTracker ||= new KnowledgeTracker());
|
|
166
|
+
return (this._knowledgeTracker ||= new KnowledgeTracker(this.options.applicationSpec));
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
experienceTracker(): ExperienceTracker {
|
|
@@ -518,4 +532,15 @@ export class ExplorBot {
|
|
|
518
532
|
private isHistorianEnabled(): boolean {
|
|
519
533
|
return this.config.ai?.agents?.historian?.enabled !== false;
|
|
520
534
|
}
|
|
535
|
+
|
|
536
|
+
private async bootstrapOptionalProvider(): Promise<void> {
|
|
537
|
+
try {
|
|
538
|
+
const provider = new AIProvider(this.config.ai);
|
|
539
|
+
await provider.validateConnection();
|
|
540
|
+
this.provider = provider;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
this.aiFailure = browserErrorMessage(error);
|
|
543
|
+
tag('debug').log(`AI provider unavailable: ${this.aiFailure}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
521
546
|
}
|
package/src/explorer.ts
CHANGED
|
@@ -6,7 +6,7 @@ import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
|
6
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
7
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
8
|
import dedent from 'dedent';
|
|
9
|
-
import type { BrowserContextOptions, Page } from 'playwright';
|
|
9
|
+
import type { Browser, BrowserContextOptions, Page } from 'playwright';
|
|
10
10
|
import { ActionResult } from './action-result.ts';
|
|
11
11
|
import Action from './action.js';
|
|
12
12
|
import type { RequestStore } from './api/request-store.ts';
|
|
@@ -103,8 +103,8 @@ class Explorer {
|
|
|
103
103
|
throw new Error('Playwright helper not available');
|
|
104
104
|
}
|
|
105
105
|
await this.connectOrLaunchBrowser();
|
|
106
|
-
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
107
|
-
await this.
|
|
106
|
+
const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
|
|
107
|
+
await this.openContextPage();
|
|
108
108
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
109
109
|
this.attachXhrCapture();
|
|
110
110
|
if (hasSession) {
|
|
@@ -123,10 +123,11 @@ class Explorer {
|
|
|
123
123
|
async stop(): Promise<void> {
|
|
124
124
|
if (!this.started) return;
|
|
125
125
|
this.started = false;
|
|
126
|
+
const attached = !!this.options?.attachedBrowser;
|
|
126
127
|
|
|
127
128
|
await this.stopCaptures();
|
|
128
129
|
|
|
129
|
-
if (this.options?.session && this.playwrightHelper?.browserContext) {
|
|
130
|
+
if (!attached && this.options?.session && this.playwrightHelper?.browserContext) {
|
|
130
131
|
const dir = path.dirname(this.options.session);
|
|
131
132
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
132
133
|
await this.playwrightHelper.browserContext.storageState({ path: this.options.session });
|
|
@@ -141,7 +142,14 @@ class Explorer {
|
|
|
141
142
|
return;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
if (attached) {
|
|
146
|
+
tag('info').log('Disconnecting from attached browser (its pages stay open)');
|
|
147
|
+
await this.playwrightHelper.browser?.close().catch((err: unknown) => {
|
|
148
|
+
debugLog('Failed to disconnect from attached browser:', err);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!attached) tag('info').log('Closing browser context (persistent browser stays running)');
|
|
145
153
|
await this.closeBrowserContext();
|
|
146
154
|
this.playwrightHelper.browser = null;
|
|
147
155
|
this.playwrightHelper.isRunning = false;
|
|
@@ -309,8 +317,16 @@ class Explorer {
|
|
|
309
317
|
}
|
|
310
318
|
|
|
311
319
|
private async connectOrLaunchBrowser(): Promise<void> {
|
|
320
|
+
if (this.options?.attachedBrowser) {
|
|
321
|
+
this.playwrightHelper.browser = this.options.attachedBrowser;
|
|
322
|
+
this.playwrightHelper.isRunning = true;
|
|
323
|
+
this.isSharedBrowser = true;
|
|
324
|
+
tag('success').log('Attached to a browser opened by another tool');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
312
328
|
const { getAliveEndpoint } = await import('./browser-server.js');
|
|
313
|
-
const endpoint = await getAliveEndpoint();
|
|
329
|
+
const endpoint = await getAliveEndpoint(this.options?.instance);
|
|
314
330
|
|
|
315
331
|
if (endpoint) {
|
|
316
332
|
const browserName = this.config.playwright.browser || 'chromium';
|
|
@@ -326,6 +342,21 @@ class Explorer {
|
|
|
326
342
|
await this.playwrightHelper._startBrowser();
|
|
327
343
|
}
|
|
328
344
|
|
|
345
|
+
private async openContextPage(): Promise<void> {
|
|
346
|
+
const attached = this.options?.attachedBrowser;
|
|
347
|
+
if (!attached) {
|
|
348
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const context = attached.contexts()[0] || (await attached.newContext());
|
|
353
|
+
const pages = context.pages();
|
|
354
|
+
const page = pages[pages.length - 1] || (await context.newPage());
|
|
355
|
+
this.playwrightHelper.browserContext = context;
|
|
356
|
+
await this.playwrightHelper._setPage(page);
|
|
357
|
+
debugLog(`Adopted attached browser page: ${page.url()}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
329
360
|
private createBrowserContextOptions(): BrowserContextOptions {
|
|
330
361
|
const helperOptions = this.playwrightHelper.options || {};
|
|
331
362
|
const contextOptions: BrowserContextOptions = {
|
|
@@ -506,6 +537,10 @@ class Explorer {
|
|
|
506
537
|
}
|
|
507
538
|
|
|
508
539
|
private async closeBrowserContext(): Promise<void> {
|
|
540
|
+
if (this.options?.attachedBrowser) {
|
|
541
|
+
this.playwrightHelper.browserContext = null;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
509
544
|
if (!this.playwrightHelper.browserContext) return;
|
|
510
545
|
await this.playwrightHelper.browserContext.close().catch((err: unknown) => {
|
|
511
546
|
debugLog('Failed to close browser context:', err);
|
|
@@ -529,7 +564,7 @@ class Explorer {
|
|
|
529
564
|
}
|
|
530
565
|
|
|
531
566
|
await this.connectOrLaunchBrowser();
|
|
532
|
-
await this.
|
|
567
|
+
await this.openContextPage();
|
|
533
568
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
534
569
|
this.attachXhrCapture();
|
|
535
570
|
this.listenToStateChanged();
|
|
@@ -631,6 +666,7 @@ class Explorer {
|
|
|
631
666
|
|
|
632
667
|
private async closeOtherTabs(): Promise<void> {
|
|
633
668
|
if (!this.playwrightHelper) return;
|
|
669
|
+
if (this.options?.attachedBrowser) return;
|
|
634
670
|
|
|
635
671
|
const context = this.playwrightHelper.page.context();
|
|
636
672
|
const pages = context.pages();
|
|
@@ -733,6 +769,8 @@ export interface ExplorerOptions {
|
|
|
733
769
|
headless?: boolean;
|
|
734
770
|
incognito?: boolean;
|
|
735
771
|
session?: string;
|
|
772
|
+
instance?: string;
|
|
773
|
+
attachedBrowser?: Browser;
|
|
736
774
|
}
|
|
737
775
|
|
|
738
776
|
export interface ExplorerDeps {
|