explorbot 0.2.2 → 0.2.4

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 (214) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +59 -38
  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 +43 -10
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  7. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  8. package/boat/doc-collector/src/cli.ts +1 -0
  9. package/boat/doc-collector/src/config.ts +4 -2
  10. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  11. package/boat/doc-collector/src/state-diagram.ts +61 -14
  12. package/boat/prima/bin/prima-cli.ts +5 -0
  13. package/boat/prima/package.json +16 -0
  14. package/boat/prima/src/activity-line.ts +33 -0
  15. package/boat/prima/src/cli.ts +251 -0
  16. package/boat/prima/src/envelope.ts +169 -0
  17. package/boat/prima/src/prima.ts +1124 -0
  18. package/boat/prima/src/pw-parser.ts +27 -0
  19. package/boat/prima/src/pw-registry.ts +74 -0
  20. package/boat/prima/src/session-log.ts +126 -0
  21. package/dist/bin/explorbot-cli.js +51 -32
  22. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  23. package/dist/boat/api-tester/src/apibot.js +3 -2
  24. package/dist/boat/api-tester/src/cli.js +2 -2
  25. package/dist/boat/api-tester/src/config.js +40 -10
  26. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  27. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  28. package/dist/boat/doc-collector/src/cli.js +1 -0
  29. package/dist/boat/doc-collector/src/config.js +4 -2
  30. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  31. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  32. package/dist/boat/prima/bin/prima-cli.js +4 -0
  33. package/dist/boat/prima/src/activity-line.js +30 -0
  34. package/dist/boat/prima/src/cli.js +220 -0
  35. package/dist/boat/prima/src/envelope.js +142 -0
  36. package/dist/boat/prima/src/prima.js +1031 -0
  37. package/dist/boat/prima/src/pw-parser.js +30 -0
  38. package/dist/boat/prima/src/pw-registry.js +65 -0
  39. package/dist/boat/prima/src/session-log.js +108 -0
  40. package/dist/models.json +3 -0
  41. package/dist/package.json +8 -3
  42. package/dist/rules/navigator/verification-actions.md +20 -0
  43. package/dist/src/action-result.d.ts +7 -0
  44. package/dist/src/action-result.js +4 -0
  45. package/dist/src/action.d.ts +7 -2
  46. package/dist/src/action.js +41 -6
  47. package/dist/src/ai/captain/mixin.js +3 -4
  48. package/dist/src/ai/captain/web-mode.js +7 -4
  49. package/dist/src/ai/captain.js +2 -0
  50. package/dist/src/ai/navigator.d.ts +11 -0
  51. package/dist/src/ai/navigator.js +21 -12
  52. package/dist/src/ai/pilot.d.ts +4 -0
  53. package/dist/src/ai/pilot.js +48 -2
  54. package/dist/src/ai/planner.d.ts +1 -0
  55. package/dist/src/ai/planner.js +6 -0
  56. package/dist/src/ai/provider.js +2 -2
  57. package/dist/src/ai/rerunner.js +2 -1
  58. package/dist/src/ai/researcher.js +2 -2
  59. package/dist/src/ai/rules.js +17 -10
  60. package/dist/src/ai/task-agent.js +5 -2
  61. package/dist/src/ai/tester.d.ts +1 -0
  62. package/dist/src/ai/tester.js +33 -20
  63. package/dist/src/ai/tools.d.ts +14 -0
  64. package/dist/src/ai/tools.js +113 -36
  65. package/dist/src/application-spec-contract.d.ts +8 -0
  66. package/dist/src/application-spec-contract.js +8 -0
  67. package/dist/src/application-spec.d.ts +15 -0
  68. package/dist/src/application-spec.js +71 -0
  69. package/dist/src/browser-server.d.ts +12 -6
  70. package/dist/src/browser-server.js +74 -19
  71. package/dist/src/commands/clean-command.js +2 -7
  72. package/dist/src/commands/init-command.d.ts +5 -0
  73. package/dist/src/commands/init-command.js +119 -1
  74. package/dist/src/commands/navigate-command.js +1 -1
  75. package/dist/src/commands/research-command.js +1 -1
  76. package/dist/src/commands/sites-command.d.ts +6 -0
  77. package/dist/src/commands/sites-command.js +23 -0
  78. package/dist/src/components/InitWizard.d.ts +10 -0
  79. package/dist/src/components/InitWizard.js +133 -0
  80. package/dist/src/components/InputReadline.d.ts +1 -0
  81. package/dist/src/components/InputReadline.js +7 -4
  82. package/dist/src/config.d.ts +25 -5
  83. package/dist/src/config.js +158 -40
  84. package/dist/src/execution-controller.d.ts +2 -0
  85. package/dist/src/execution-controller.js +6 -0
  86. package/dist/src/explorbot.d.ts +11 -1
  87. package/dist/src/explorbot.js +27 -6
  88. package/dist/src/explorer.d.ts +4 -1
  89. package/dist/src/explorer.js +42 -9
  90. package/dist/src/global-config.d.ts +22 -0
  91. package/dist/src/global-config.js +117 -0
  92. package/dist/src/knowledge-tracker.d.ts +5 -1
  93. package/dist/src/knowledge-tracker.js +14 -1
  94. package/dist/src/playwright-recorder.js +30 -0
  95. package/dist/src/remote.d.ts +54 -0
  96. package/dist/src/remote.js +229 -0
  97. package/dist/src/reporter.d.ts +1 -0
  98. package/dist/src/reporter.js +7 -1
  99. package/dist/src/state-manager.d.ts +2 -1
  100. package/dist/src/stats.d.ts +1 -0
  101. package/dist/src/stats.js +1 -0
  102. package/dist/src/test-plan.d.ts +2 -0
  103. package/dist/src/test-plan.js +7 -0
  104. package/dist/src/utils/aria.d.ts +2 -8
  105. package/dist/src/utils/aria.js +69 -40
  106. package/dist/src/utils/cli-name.js +6 -2
  107. package/dist/src/utils/html.js +1 -0
  108. package/dist/src/utils/logger.d.ts +6 -0
  109. package/dist/src/utils/logger.js +24 -0
  110. package/dist/src/utils/page-readiness.js +18 -1
  111. package/dist/src/utils/test-files.js +1 -2
  112. package/dist/src/utils/url-matcher.d.ts +1 -0
  113. package/dist/src/utils/url-matcher.js +12 -0
  114. package/dist/src/utils/web-element.d.ts +2 -0
  115. package/dist/src/utils/web-element.js +8 -0
  116. package/dist/src/utils/web-sandbox.d.ts +1 -1
  117. package/dist/src/utils/web-sandbox.js +2 -3
  118. package/docs/api-testing/basics.md +90 -0
  119. package/docs/api-testing/planning.md +57 -0
  120. package/docs/api-testing/running-tests.md +55 -0
  121. package/docs/assets/cloud-report.png +0 -0
  122. package/docs/assets/html-report.png +0 -0
  123. package/docs/assets/langfuse-trace.png +0 -0
  124. package/docs/assets/successful-explore-run.png +0 -0
  125. package/docs/basics/getting-started.md +140 -0
  126. package/docs/basics/prerequisites.md +63 -0
  127. package/docs/basics/providers.md +362 -0
  128. package/docs/basics/running.md +78 -0
  129. package/docs/contributing/ai-integration-tests.md +57 -0
  130. package/docs/contributing/contributing.md +90 -0
  131. package/docs/contributing/demo-videos.md +36 -0
  132. package/docs/contributing/npm-package.md +138 -0
  133. package/docs/contributing/observability.md +227 -0
  134. package/docs/contributing/regression-tests.md +103 -0
  135. package/docs/contributing/testing.md +95 -0
  136. package/docs/doc-collection/basics.md +128 -0
  137. package/docs/doc-collection/crawling.md +67 -0
  138. package/docs/doc-collection/interactive-mode.md +99 -0
  139. package/docs/index.json +86 -0
  140. package/docs/reference/commands.md +994 -0
  141. package/docs/reference/configuration.md +569 -0
  142. package/docs/reference/scripting.md +303 -0
  143. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  144. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  145. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  146. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  147. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  148. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  149. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  150. package/docs/web-testing/agents.md +158 -0
  151. package/docs/web-testing/automated-tests.md +134 -0
  152. package/docs/web-testing/basics.md +91 -0
  153. package/docs/web-testing/customization.md +131 -0
  154. package/docs/web-testing/hooks.md +238 -0
  155. package/docs/web-testing/page-interaction.md +84 -0
  156. package/docs/web-testing/planner.md +122 -0
  157. package/docs/web-testing/rerun.md +164 -0
  158. package/docs/web-testing/researcher.md +380 -0
  159. package/docs/workflow/agentic-usage.md +233 -0
  160. package/docs/workflow/application-spec.md +73 -0
  161. package/docs/workflow/ci.md +202 -0
  162. package/docs/workflow/knowledge.md +310 -0
  163. package/docs/workflow/planning-styles.md +67 -0
  164. package/docs/workflow/reporting.md +133 -0
  165. package/docs/workflow/test-plans.md +90 -0
  166. package/models.json +3 -0
  167. package/package.json +8 -3
  168. package/rules/navigator/verification-actions.md +20 -0
  169. package/src/action-result.ts +11 -0
  170. package/src/action.ts +47 -7
  171. package/src/ai/captain/mixin.ts +3 -3
  172. package/src/ai/captain/web-mode.ts +7 -4
  173. package/src/ai/captain.ts +3 -0
  174. package/src/ai/navigator.ts +26 -15
  175. package/src/ai/pilot.ts +53 -2
  176. package/src/ai/planner.ts +7 -0
  177. package/src/ai/provider.ts +2 -4
  178. package/src/ai/rerunner.ts +2 -1
  179. package/src/ai/researcher.ts +2 -2
  180. package/src/ai/rules.ts +17 -10
  181. package/src/ai/task-agent.ts +4 -2
  182. package/src/ai/tester.ts +32 -18
  183. package/src/ai/tools.ts +132 -36
  184. package/src/application-spec-contract.ts +10 -0
  185. package/src/application-spec.ts +87 -0
  186. package/src/browser-server.ts +74 -19
  187. package/src/commands/clean-command.ts +1 -6
  188. package/src/commands/init-command.ts +146 -1
  189. package/src/commands/navigate-command.ts +1 -1
  190. package/src/commands/research-command.ts +1 -1
  191. package/src/commands/sites-command.ts +27 -0
  192. package/src/components/InitWizard.tsx +166 -0
  193. package/src/components/InputReadline.tsx +8 -4
  194. package/src/config.ts +177 -43
  195. package/src/execution-controller.ts +8 -0
  196. package/src/explorbot.ts +34 -7
  197. package/src/explorer.ts +47 -9
  198. package/src/global-config.ts +148 -0
  199. package/src/knowledge-tracker.ts +17 -1
  200. package/src/playwright-recorder.ts +23 -0
  201. package/src/remote.ts +238 -0
  202. package/src/reporter.ts +7 -1
  203. package/src/state-manager.ts +2 -1
  204. package/src/stats.ts +1 -0
  205. package/src/test-plan.ts +9 -0
  206. package/src/utils/aria.ts +65 -45
  207. package/src/utils/cli-name.ts +5 -2
  208. package/src/utils/html.ts +1 -0
  209. package/src/utils/logger.ts +24 -1
  210. package/src/utils/page-readiness.ts +24 -1
  211. package/src/utils/test-files.ts +1 -2
  212. package/src/utils/url-matcher.ts +13 -0
  213. package/src/utils/web-element.ts +9 -0
  214. package/src/utils/web-sandbox.ts +3 -4
package/src/config.ts CHANGED
@@ -1,18 +1,22 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import path, { basename, dirname, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
4
5
  import { parseEnv } from 'node:util';
6
+ import dedent from 'dedent';
5
7
  import matter from 'gray-matter';
6
- import { log } from './utils/logger.js';
7
-
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(),
8
+ import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
9
+ import { getCliName } from './utils/cli-name.js';
10
+ import { log, tag } from './utils/logger.js';
11
+
12
+ export const PROVIDERS: Record<string, ProviderInfo> = {
13
+ openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
14
+ anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
15
+ google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
16
+ groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
17
+ mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
18
+ openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
19
+ sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
16
20
  };
17
21
 
18
22
  let cachedOutputRoot: string | null = null;
@@ -194,6 +198,7 @@ interface HtmlConfig {
194
198
  interface ActionConfig {
195
199
  delay?: number;
196
200
  retries?: number;
201
+ timeout?: number;
197
202
  }
198
203
 
199
204
  interface ReporterConfig {
@@ -227,6 +232,7 @@ interface ExplorbotConfig {
227
232
  knowledge: string;
228
233
  experience: string;
229
234
  output: string;
235
+ spec?: string;
230
236
  };
231
237
  experience?: {
232
238
  maxReadLines?: number;
@@ -260,7 +266,8 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
260
266
  { name: 'EXPLORBOT_URL', required: true, description: 'Base URL to test; the API boat reads it as the base endpoint' },
261
267
  { name: 'EXPLORBOT_VISION_MODEL', description: 'Screenshot analysis; overrides the provider recommendation' },
262
268
  { 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' },
269
+ { name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites' },
270
+ { name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
264
271
  { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
265
272
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
266
273
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
@@ -299,14 +306,26 @@ export class ConfigParser {
299
306
  private static recommended: Record<string, Record<string, string>> | null = null;
300
307
  private config: ExplorbotConfig | null = null;
301
308
  private configPath: string | null = null;
302
- private runtimeBaseUrlOverride: string | null = null;
309
+ private runtimeTarget: string | null = null;
310
+ private site: SiteRecord | null = null;
311
+ private siteStartPath = '/';
303
312
 
304
313
  private constructor() {}
305
314
 
306
- public static loadEnv(filePath: string): void {
315
+ public static loadEnv(filePath: string, keepExisting = false): void {
307
316
  const resolved = resolve(filePath);
308
317
  if (!existsSync(resolved)) return;
309
- Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
318
+
319
+ const parsed = parseEnv(readFileSync(resolved, 'utf8'));
320
+ if (!keepExisting) {
321
+ Object.assign(process.env, parsed);
322
+ return;
323
+ }
324
+
325
+ for (const [key, value] of Object.entries(parsed)) {
326
+ if (key in process.env) continue;
327
+ process.env[key] = value as string;
328
+ }
310
329
  }
311
330
 
312
331
  public static recommendedModels(): Record<string, Record<string, string>> {
@@ -325,8 +344,10 @@ export class ConfigParser {
325
344
  config?: string;
326
345
  path?: string;
327
346
  baseUrl?: string;
347
+ from?: string;
328
348
  }): Promise<ExplorbotConfig> {
329
- if (this.config && !options?.config && !options?.path && this.runtimeBaseUrlOverride === (options?.baseUrl || null)) {
349
+ const target = options?.baseUrl || options?.from || null;
350
+ if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
330
351
  return this.config;
331
352
  }
332
353
 
@@ -342,6 +363,7 @@ export class ConfigParser {
342
363
  }
343
364
 
344
365
  ConfigParser.loadEnv('.env');
366
+ ConfigParser.loadEnv(globalEnvPath(), true);
345
367
 
346
368
  try {
347
369
  const resolvedPath = options?.config || this.findConfigFile();
@@ -361,16 +383,25 @@ export class ConfigParser {
361
383
  }
362
384
 
363
385
  if (!resolvedPath) {
364
- const outputRoot = resolveOutputRoot();
365
- loadedConfig = await this.buildEnvConfig(options?.baseUrl, outputRoot);
386
+ let envUrl = options?.baseUrl;
387
+ if (!envUrl && target?.startsWith('http')) envUrl = target;
388
+
389
+ const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
390
+ loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
366
391
  sourcePath = join(outputRoot, 'explorbot.config.js');
367
392
 
368
393
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
369
394
  }
370
395
 
371
396
  this.config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
372
- this.runtimeBaseUrlOverride = options?.baseUrl || null;
397
+ await resolveConfigModels(this.config.ai);
398
+ this.runtimeTarget = target;
373
399
  this.configPath = sourcePath;
400
+ this.site = null;
401
+
402
+ if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
403
+ this.enterGlobalMode(this.config, target);
404
+ }
374
405
 
375
406
  // Restore original directory after successful config load
376
407
  if (options?.path && originalCwd !== process.cwd()) {
@@ -383,6 +414,7 @@ export class ConfigParser {
383
414
  if (options?.path && originalCwd !== process.cwd()) {
384
415
  process.chdir(originalCwd);
385
416
  }
417
+ if (error instanceof ConfigMissingError) throw error;
386
418
  throw new Error(`Failed to load configuration: ${error}`);
387
419
  }
388
420
  }
@@ -400,21 +432,45 @@ export class ConfigParser {
400
432
 
401
433
  public getOutputDir(): string {
402
434
  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');
435
+ if (!this.configPath) throw new Error('Config path not found');
436
+ return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
406
437
  }
407
438
 
408
439
  public getProjectRoot(): string {
440
+ if (this.site) return this.site.dir;
409
441
  const configPath = this.getConfigPath();
410
442
  if (configPath) return path.dirname(configPath);
411
443
  return process.cwd();
412
444
  }
413
445
 
414
446
  public resolveProjectDir(relativeDir: string): string {
415
- const configPath = this.getConfigPath();
416
- if (!configPath) return relativeDir;
417
- return path.join(path.dirname(configPath), relativeDir);
447
+ if (!this.configPath) return relativeDir;
448
+ return path.join(this.getProjectRoot(), relativeDir);
449
+ }
450
+
451
+ public isGlobalMode(): boolean {
452
+ return !!this.site;
453
+ }
454
+
455
+ public getSite(): SiteRecord | null {
456
+ return this.site;
457
+ }
458
+
459
+ public resolveTargetPath(target?: string): string {
460
+ if (!this.site) {
461
+ const configured = this.config?.playwright?.url || this.config?.web?.url;
462
+ const targetOrigin = target ? URL.parse(target)?.origin : null;
463
+ const baseOrigin = configured ? URL.parse(configured)?.origin : null;
464
+ if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
465
+ tag('warning').log(`Exploring ${targetOrigin} but base URL is ${baseOrigin}. Relative navigation resolves against the base URL — set web.url to ${targetOrigin} to avoid it.`);
466
+ }
467
+ return target || '/';
468
+ }
469
+ if (!target) return this.siteStartPath;
470
+
471
+ const resolved = resolveSiteTarget(target, this.site.url);
472
+ if (resolved.baseUrl !== this.site.url) return target;
473
+ return resolved.path;
418
474
  }
419
475
 
420
476
  public getStatesDir(): string {
@@ -435,7 +491,9 @@ export class ConfigParser {
435
491
  if (ConfigParser.instance) {
436
492
  ConfigParser.instance.config = null;
437
493
  ConfigParser.instance.configPath = null;
438
- ConfigParser.instance.runtimeBaseUrlOverride = null;
494
+ ConfigParser.instance.runtimeTarget = null;
495
+ ConfigParser.instance.site = null;
496
+ ConfigParser.instance.siteStartPath = '/';
439
497
  }
440
498
  }
441
499
 
@@ -486,11 +544,22 @@ export class ConfigParser {
486
544
  }
487
545
  }
488
546
 
547
+ private enterGlobalMode(config: ExplorbotConfig, target: string | null): void {
548
+ const site = resolveSiteTarget(target || undefined, config.web?.url || config.playwright?.url);
549
+ this.site = registerSite(site.baseUrl);
550
+ this.siteStartPath = site.path;
551
+
552
+ config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
553
+ config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
554
+
555
+ log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
556
+ }
557
+
489
558
  private async buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig> {
490
559
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
491
560
  const modelSpec = process.env.EXPLORBOT_AI_MODEL;
492
561
  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');
562
+ throw new ConfigMissingError(missingConfigMessage());
494
563
  }
495
564
  if (modelSpec && !provider && !modelSpec.includes('/')) {
496
565
  throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
@@ -524,11 +593,14 @@ export class ConfigParser {
524
593
  if (agenticSpec) ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
525
594
  if (!agenticSpec && recommended.agenticModel) ai.agenticModel = await resolveModel(provider!, 'agenticModel');
526
595
 
596
+ const dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
597
+ if (process.env.EXPLORBOT_OUTPUT) dirs.output = '.';
598
+
527
599
  return {
528
600
  playwright: { browser: 'chromium', url, show: false },
529
601
  ai,
530
- dirs: { knowledge: 'knowledge', experience: 'experience', output: '.' },
531
- experience: { disabled: true },
602
+ dirs,
603
+ experience: { disabled: !!process.env.EXPLORBOT_EPHEMERAL },
532
604
  };
533
605
  }
534
606
 
@@ -542,22 +614,24 @@ export class ConfigParser {
542
614
  }
543
615
  }
544
616
 
545
- return null;
617
+ if (envConfigRequested()) return null;
618
+ return findGlobalConfig();
546
619
  }
547
620
 
548
621
  private async loadConfigModule(configPath: string): Promise<any> {
549
622
  const ext = configPath.split('.').pop();
623
+ const moduleUrl = pathToFileURL(resolve(configPath)).href;
550
624
 
551
625
  if (ext === 'ts') {
552
626
  try {
553
- const module = await import(configPath);
627
+ const module = await import(moduleUrl);
554
628
  return module;
555
629
  } catch (error) {
556
630
  const require = (await import('node:module')).createRequire(import.meta.url);
557
631
  return require(configPath);
558
632
  }
559
633
  } else if (ext === 'js' || ext === 'mjs') {
560
- const module = await import(configPath);
634
+ const module = await import(moduleUrl);
561
635
  return module;
562
636
  } else {
563
637
  const content = readFileSync(configPath, 'utf8');
@@ -664,30 +738,84 @@ export async function resolveModel(spec: string, role: ModelRole = 'model'): Pro
664
738
  return createModel(spec, modelId);
665
739
  }
666
740
 
667
- export function resolveOutputRoot(): string {
741
+ export class ConfigMissingError extends Error {}
742
+
743
+ export function envConfigRequested(): boolean {
744
+ return !!(process.env.EXPLORBOT_AI_PROVIDER || process.env.EXPLORBOT_AI_MODEL);
745
+ }
746
+
747
+ export function missingConfigMessage(configFile = 'explorbot.config.js'): string {
748
+ const cli = getCliName();
749
+ return dedent`
750
+ No AI configuration found. Set up explorbot in one of these ways:
751
+
752
+ Global - configure this machine once, then run from any directory:
753
+ ${cli} init --global
754
+
755
+ Local - create ${configFile} for this project:
756
+ ${cli} init
757
+
758
+ Environment - one-off run, no files written:
759
+ EXPLORBOT_AI_PROVIDER=openrouter EXPLORBOT_URL=https://your-app.example.com ${cli} ...
760
+
761
+ Providers: ${Object.keys(PROVIDERS).join(', ')}
762
+ `;
763
+ }
764
+
765
+ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
766
+ if (!ai) return;
767
+
768
+ const roles: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
769
+ for (const role of roles) {
770
+ if (typeof ai[role] === 'string') ai[role] = await resolveModel(ai[role], role);
771
+ }
772
+
773
+ for (const agent of Object.values(ai.agents || {})) {
774
+ if (typeof agent?.model === 'string') agent.model = await resolveModel(agent.model);
775
+ }
776
+ }
777
+
778
+ export function resolveOutputRoot(baseUrl?: string): string {
668
779
  if (cachedOutputRoot) return cachedOutputRoot;
669
780
 
670
781
  const configured = process.env.EXPLORBOT_OUTPUT;
671
- if (!configured) {
672
- cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
782
+ if (configured) {
783
+ cachedOutputRoot = resolve(configured);
784
+ mkdirSync(cachedOutputRoot, { recursive: true });
785
+ return cachedOutputRoot;
786
+ }
787
+
788
+ if (baseUrl) {
789
+ cachedOutputRoot = resolveStateRoot(baseUrl, !!process.env.EXPLORBOT_EPHEMERAL);
673
790
  return cachedOutputRoot;
674
791
  }
675
792
 
676
- cachedOutputRoot = resolve(configured);
677
- mkdirSync(cachedOutputRoot, { recursive: true });
793
+ cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
678
794
  return cachedOutputRoot;
679
795
  }
680
796
 
797
+ export function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string {
798
+ const url = URL.parse(baseUrl);
799
+ if (ephemeral || !url?.host) return mkdtempSync(join(tmpdir(), 'explorbot-'));
800
+
801
+ return registerSite(url.origin).dir;
802
+ }
803
+
681
804
  export function materializeKnowledge(outputRoot: string): void {
682
805
  const inline = process.env.EXPLORBOT_KNOWLEDGE;
683
806
  const knowledgeFile = process.env.EXPLORBOT_KNOWLEDGE_FILE;
807
+ const knowledgeDir = join(outputRoot, 'knowledge');
808
+ const globalFile = join(knowledgeDir, 'global.md');
809
+ const envDir = join(knowledgeDir, 'env');
810
+
811
+ if (!inline) rmSync(globalFile, { force: true });
812
+ rmSync(envDir, { recursive: true, force: true });
684
813
  if (!inline && !knowledgeFile) return;
685
814
 
686
- const knowledgeDir = join(outputRoot, 'knowledge');
687
815
  mkdirSync(knowledgeDir, { recursive: true });
688
816
 
689
817
  if (inline) {
690
- writeFileSync(join(knowledgeDir, 'global.md'), matter.stringify(inline, { url: '*', endpoint: '*' }));
818
+ writeFileSync(globalFile, matter.stringify(inline, { url: '*', endpoint: '*' }));
691
819
  }
692
820
 
693
821
  if (!knowledgeFile) return;
@@ -696,23 +824,29 @@ export function materializeKnowledge(outputRoot: string): void {
696
824
  if (!existsSync(source)) {
697
825
  throw new Error(`Knowledge file from EXPLORBOT_KNOWLEDGE_FILE not found: ${source}`);
698
826
  }
699
- copyFileSync(source, join(knowledgeDir, basename(source)));
827
+ mkdirSync(envDir, { recursive: true });
828
+ copyFileSync(source, join(envDir, basename(source)));
700
829
  }
701
830
 
702
831
  export async function createModel(provider: string, modelId: string): Promise<any> {
703
- const factory = PROVIDERS[provider];
704
- if (!factory) {
832
+ const info = PROVIDERS[provider];
833
+ if (!info) {
705
834
  throw new Error(`Unknown AI provider "${provider}". Supported providers: ${Object.keys(PROVIDERS).join(', ')}`);
706
835
  }
707
- return (await factory())(modelId);
836
+ return (await info.load())(modelId);
708
837
  }
709
838
 
710
839
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
711
840
 
841
+ interface ProviderInfo {
842
+ envKey: string;
843
+ load: () => Promise<(modelId: string) => any>;
844
+ }
845
+
712
846
  interface EnvVar {
713
847
  name: string;
714
848
  description: string;
715
849
  required?: boolean;
716
850
  }
717
851
 
718
- export type { ModelRole, EnvVar };
852
+ export type { ModelRole, EnvVar, ProviderInfo };
@@ -27,6 +27,14 @@ export class ExecutionController extends EventEmitter {
27
27
  this.inputCallback = callback;
28
28
  }
29
29
 
30
+ hasInputCallback(): boolean {
31
+ return !!this.inputCallback;
32
+ }
33
+
34
+ clearInputCallback(): void {
35
+ this.inputCallback = null;
36
+ }
37
+
30
38
  startExecution(): void {
31
39
  this.interrupted = false;
32
40
  this.abortController = new AbortController();
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';
@@ -19,7 +20,7 @@ import { createAgentTools } from './ai/tools.ts';
19
20
  import { ApiClient } from './api/api-client.ts';
20
21
  import { RequestStore } from './api/request-store.ts';
21
22
  import { loadSpec } from './api/spec-reader.ts';
22
- import type { ExplorbotConfig } from './config.js';
23
+ import type { ExplorbotConfig, ReporterConfig } from './config.js';
23
24
  import { ConfigParser } from './config.ts';
24
25
  import { ExperienceTracker } from './experience-tracker.ts';
25
26
  import Explorer from './explorer.ts';
@@ -46,6 +47,11 @@ export interface ExplorBotOptions {
46
47
  headless?: boolean;
47
48
  incognito?: boolean;
48
49
  session?: string | boolean;
50
+ instance?: string;
51
+ optionalAi?: boolean;
52
+ reporter?: ReporterConfig;
53
+ attachedBrowser?: Browser;
54
+ applicationSpec?: string;
49
55
  }
50
56
 
51
57
  export type UserResolveFunction = (error?: Error, showWelcome?: boolean) => Promise<string | null>;
@@ -61,6 +67,7 @@ export class ExplorBot {
61
67
  private planFeature?: string;
62
68
  lastPlanError: Error | null = null;
63
69
  lastSavedPlanPath: string | null = null;
70
+ private aiFailure: string | null = null;
64
71
  private agents: Record<string, any> = {};
65
72
  private sessionPlans: Plan[] = [];
66
73
  private lastReportedTestCount = 0;
@@ -88,6 +95,10 @@ export class ExplorBot {
88
95
  this.userResolveFn = fn;
89
96
  }
90
97
 
98
+ attachBrowser(browser: Browser): void {
99
+ this.options.attachedBrowser = browser;
100
+ }
101
+
91
102
  async start(): Promise<void> {
92
103
  if (this.explorer) {
93
104
  return;
@@ -104,7 +115,7 @@ export class ExplorBot {
104
115
  playwrightRecorder: this.playwrightRecorder(),
105
116
  });
106
117
  await this.explorer.start();
107
- if (!this.options.incognito) {
118
+ if (!this.options.incognito && this.provider) {
108
119
  await this.agentExperienceCompactor().autocompact();
109
120
  }
110
121
  } catch (error) {
@@ -118,22 +129,27 @@ export class ExplorBot {
118
129
  if (this.provider) return;
119
130
  this.config = await this.configParser.loadConfig(this.options);
120
131
  if (this.options.session === true) this.options.session = path.join(this.configParser.getOutputDir(), 'session.json');
132
+ if (typeof this.options.session === 'string') this.options.session = path.resolve(this.options.path || '.', this.options.session);
133
+ if (this.options.optionalAi) return this.bootstrapOptionalProvider();
121
134
  this.provider = new AIProvider(this.config.ai);
122
135
  await this.provider.validateConnection();
123
136
  }
124
137
 
138
+ aiFailureReason(): string | null {
139
+ return this.aiFailure;
140
+ }
141
+
125
142
  async stop(): Promise<void> {
126
143
  this.agents.quartermaster?.stop();
127
144
  await this.explorer?.stop();
128
145
  }
129
146
 
130
147
  async visitInitialState(): Promise<void> {
131
- const url = this.options.from || '/';
132
- await this.visit(url);
148
+ await this.visit(this.options.from || '/');
133
149
  }
134
150
 
135
151
  async visit(url: string): Promise<void> {
136
- return this.agentNavigator().visit(url);
152
+ return this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
137
153
  }
138
154
 
139
155
  async openTab(): Promise<void> {
@@ -149,7 +165,7 @@ export class ExplorBot {
149
165
  }
150
166
 
151
167
  knowledgeTracker(): KnowledgeTracker {
152
- return (this._knowledgeTracker ||= new KnowledgeTracker());
168
+ return (this._knowledgeTracker ||= new KnowledgeTracker(this.options.applicationSpec));
153
169
  }
154
170
 
155
171
  experienceTracker(): ExperienceTracker {
@@ -161,7 +177,7 @@ export class ExplorBot {
161
177
  }
162
178
 
163
179
  reporter(): Reporter {
164
- return (this._reporter ||= new Reporter(this.config.reporter, this.stateManager()));
180
+ return (this._reporter ||= new Reporter(this.options.reporter || this.config.reporter, this.stateManager()));
165
181
  }
166
182
 
167
183
  requestStore(): RequestStore {
@@ -518,4 +534,15 @@ export class ExplorBot {
518
534
  private isHistorianEnabled(): boolean {
519
535
  return this.config.ai?.agents?.historian?.enabled !== false;
520
536
  }
537
+
538
+ private async bootstrapOptionalProvider(): Promise<void> {
539
+ try {
540
+ const provider = new AIProvider(this.config.ai);
541
+ await provider.validateConnection();
542
+ this.provider = provider;
543
+ } catch (error) {
544
+ this.aiFailure = browserErrorMessage(error);
545
+ tag('debug').log(`AI provider unavailable: ${this.aiFailure}`);
546
+ }
547
+ }
521
548
  }
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();
@@ -685,11 +721,11 @@ class Explorer {
685
721
  if (this.observedTestPages.has(page)) return;
686
722
 
687
723
  this.testPageErrorHandler ||= (err: Error) => {
688
- this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
724
+ this._activeTest?.addObservation(`Console error: ${err.message}`);
689
725
  };
690
726
  this.testConsoleHandler ||= (msg: any) => {
691
727
  if (msg.type() !== 'error') return;
692
- this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
728
+ this._activeTest?.addObservation(`Console error: ${msg.text()}`);
693
729
  };
694
730
  this.testDialogHandler ||= (dialog: any) => {
695
731
  const dialogType = dialog.type();
@@ -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 {