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.
Files changed (101) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +52 -37
  3. package/boat/api-tester/src/apibot.ts +4 -2
  4. package/boat/api-tester/src/cli.ts +2 -2
  5. package/boat/api-tester/src/config.ts +39 -8
  6. package/boat/doc-collector/src/cli.ts +1 -0
  7. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  8. package/boat/doc-collector/src/state-diagram.ts +61 -14
  9. package/boat/prima/bin/prima-cli.ts +5 -0
  10. package/boat/prima/package.json +16 -0
  11. package/boat/prima/src/cli.ts +222 -0
  12. package/boat/prima/src/envelope.ts +141 -0
  13. package/boat/prima/src/prima.ts +705 -0
  14. package/boat/prima/src/pw-parser.ts +17 -0
  15. package/boat/prima/src/pw-registry.ts +75 -0
  16. package/dist/bin/explorbot-cli.js +44 -31
  17. package/dist/boat/api-tester/src/apibot.js +3 -2
  18. package/dist/boat/api-tester/src/cli.js +2 -2
  19. package/dist/boat/api-tester/src/config.js +36 -8
  20. package/dist/boat/doc-collector/src/cli.js +1 -0
  21. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  22. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  23. package/dist/boat/prima/bin/prima-cli.js +4 -0
  24. package/dist/boat/prima/src/cli.js +200 -0
  25. package/dist/boat/prima/src/envelope.js +116 -0
  26. package/dist/boat/prima/src/prima.js +635 -0
  27. package/dist/boat/prima/src/pw-parser.js +18 -0
  28. package/dist/boat/prima/src/pw-registry.js +66 -0
  29. package/dist/models.json +3 -0
  30. package/dist/package.json +6 -2
  31. package/dist/src/action.d.ts +5 -2
  32. package/dist/src/action.js +5 -5
  33. package/dist/src/ai/captain/mixin.js +3 -4
  34. package/dist/src/ai/captain/web-mode.js +1 -1
  35. package/dist/src/ai/navigator.d.ts +4 -0
  36. package/dist/src/ai/navigator.js +11 -6
  37. package/dist/src/ai/planner.d.ts +1 -0
  38. package/dist/src/ai/planner.js +6 -0
  39. package/dist/src/ai/researcher.js +1 -1
  40. package/dist/src/ai/task-agent.js +1 -1
  41. package/dist/src/ai/tester.d.ts +1 -0
  42. package/dist/src/ai/tester.js +13 -0
  43. package/dist/src/application-spec-contract.d.ts +8 -0
  44. package/dist/src/application-spec-contract.js +8 -0
  45. package/dist/src/application-spec.d.ts +15 -0
  46. package/dist/src/application-spec.js +71 -0
  47. package/dist/src/browser-server.d.ts +12 -6
  48. package/dist/src/browser-server.js +74 -19
  49. package/dist/src/commands/clean-command.js +2 -7
  50. package/dist/src/commands/init-command.d.ts +5 -0
  51. package/dist/src/commands/init-command.js +119 -1
  52. package/dist/src/commands/navigate-command.js +1 -1
  53. package/dist/src/commands/research-command.js +1 -1
  54. package/dist/src/commands/sites-command.d.ts +6 -0
  55. package/dist/src/commands/sites-command.js +23 -0
  56. package/dist/src/components/InitWizard.d.ts +10 -0
  57. package/dist/src/components/InitWizard.js +133 -0
  58. package/dist/src/components/InputReadline.d.ts +1 -0
  59. package/dist/src/components/InputReadline.js +7 -4
  60. package/dist/src/config.d.ts +24 -5
  61. package/dist/src/config.js +146 -37
  62. package/dist/src/explorbot.d.ts +9 -0
  63. package/dist/src/explorbot.js +24 -5
  64. package/dist/src/explorer.d.ts +4 -1
  65. package/dist/src/explorer.js +40 -6
  66. package/dist/src/global-config.d.ts +22 -0
  67. package/dist/src/global-config.js +117 -0
  68. package/dist/src/knowledge-tracker.d.ts +5 -1
  69. package/dist/src/knowledge-tracker.js +14 -1
  70. package/dist/src/utils/cli-name.js +6 -2
  71. package/dist/src/utils/test-files.js +1 -2
  72. package/dist/src/utils/url-matcher.d.ts +1 -0
  73. package/dist/src/utils/url-matcher.js +9 -0
  74. package/models.json +3 -0
  75. package/package.json +6 -2
  76. package/src/action.ts +9 -5
  77. package/src/ai/captain/mixin.ts +3 -3
  78. package/src/ai/captain/web-mode.ts +1 -1
  79. package/src/ai/navigator.ts +12 -7
  80. package/src/ai/planner.ts +7 -0
  81. package/src/ai/researcher.ts +1 -1
  82. package/src/ai/task-agent.ts +1 -1
  83. package/src/ai/tester.ts +15 -0
  84. package/src/application-spec-contract.ts +10 -0
  85. package/src/application-spec.ts +87 -0
  86. package/src/browser-server.ts +74 -19
  87. package/src/commands/clean-command.ts +1 -6
  88. package/src/commands/init-command.ts +146 -1
  89. package/src/commands/navigate-command.ts +1 -1
  90. package/src/commands/research-command.ts +1 -1
  91. package/src/commands/sites-command.ts +27 -0
  92. package/src/components/InitWizard.tsx +166 -0
  93. package/src/components/InputReadline.tsx +8 -4
  94. package/src/config.ts +162 -39
  95. package/src/explorbot.ts +30 -5
  96. package/src/explorer.ts +45 -7
  97. package/src/global-config.ts +148 -0
  98. package/src/knowledge-tracker.ts +17 -1
  99. package/src/utils/cli-name.ts +5 -2
  100. package/src/utils/test-files.ts +1 -2
  101. 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, () => Promise<(modelId: string) => any>> = {
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 a fresh temp directory' },
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 runtimeBaseUrlOverride: string | null = null;
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
- Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
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
- if (this.config && !options?.config && !options?.path && this.runtimeBaseUrlOverride === (options?.baseUrl || null)) {
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
- const outputRoot = resolveOutputRoot();
365
- loadedConfig = await this.buildEnvConfig(options?.baseUrl, outputRoot);
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.runtimeBaseUrlOverride = options?.baseUrl || null;
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
- const configPath = this.getConfigPath();
404
- if (!configPath) throw new Error('Config path not found');
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
- const configPath = this.getConfigPath();
416
- if (!configPath) return relativeDir;
417
- return path.join(path.dirname(configPath), relativeDir);
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.runtimeBaseUrlOverride = null;
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 Error('No configuration file found. Please create explorbot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
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: { knowledge: 'knowledge', experience: 'experience', output: '.' },
531
- experience: { disabled: true },
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 function resolveOutputRoot(): string {
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 (!configured) {
672
- cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
771
+ if (configured) {
772
+ cachedOutputRoot = resolve(configured);
773
+ mkdirSync(cachedOutputRoot, { recursive: true });
673
774
  return cachedOutputRoot;
674
775
  }
675
776
 
676
- cachedOutputRoot = resolve(configured);
677
- mkdirSync(cachedOutputRoot, { recursive: true });
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(join(knowledgeDir, 'global.md'), matter.stringify(inline, { url: '*', endpoint: '*' }));
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
- copyFileSync(source, join(knowledgeDir, basename(source)));
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 factory = PROVIDERS[provider];
704
- if (!factory) {
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 factory())(modelId);
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
- const url = this.options.from || '/';
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.playwrightHelper._createContextPage(this.createBrowserContextOptions());
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
- tag('info').log('Closing browser context (persistent browser stays running)');
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.playwrightHelper._createContextPage(this.createBrowserContextOptions());
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 {