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
@@ -9,17 +9,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import path, { basename, dirname, join, resolve } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
12
13
  import { parseEnv } from 'node:util';
14
+ import dedent from 'dedent';
13
15
  import matter from 'gray-matter';
14
- import { log } from './utils/logger.js';
16
+ import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
17
+ import { getCliName } from './utils/cli-name.js';
18
+ import { log, tag } from './utils/logger.js';
15
19
  export const PROVIDERS = {
16
- openai: async () => (await import('@ai-sdk/openai')).createOpenAI(),
17
- anthropic: async () => (await import('@ai-sdk/anthropic')).createAnthropic(),
18
- google: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI(),
19
- groq: async () => (await import('@ai-sdk/groq')).createGroq(),
20
- mistral: async () => (await import('@ai-sdk/mistral')).createMistral(),
21
- openrouter: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter(),
22
- sambanova: async () => (await import('sambanova-ai-provider')).createSambaNova(),
20
+ openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
21
+ anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
22
+ google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
23
+ groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
24
+ mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
25
+ openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
26
+ sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
23
27
  };
24
28
  let cachedOutputRoot = null;
25
29
  const config = {
@@ -38,7 +42,8 @@ export const EXPLORBOT_ENV_VARS = [
38
42
  { name: 'EXPLORBOT_URL', required: true, description: 'Base URL to test; the API boat reads it as the base endpoint' },
39
43
  { name: 'EXPLORBOT_VISION_MODEL', description: 'Screenshot analysis; overrides the provider recommendation' },
40
44
  { name: 'EXPLORBOT_AGENTIC_MODEL', description: 'Captain and Pilot decisions; overrides the provider recommendation' },
41
- { name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to a fresh temp directory' },
45
+ { name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites' },
46
+ { name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
42
47
  { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
43
48
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
44
49
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
@@ -49,13 +54,24 @@ export class ConfigParser {
49
54
  static recommended = null;
50
55
  config = null;
51
56
  configPath = null;
52
- runtimeBaseUrlOverride = null;
57
+ runtimeTarget = null;
58
+ site = null;
59
+ siteStartPath = '/';
53
60
  constructor() { }
54
- static loadEnv(filePath) {
61
+ static loadEnv(filePath, keepExisting = false) {
55
62
  const resolved = resolve(filePath);
56
63
  if (!existsSync(resolved))
57
64
  return;
58
- Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
65
+ const parsed = parseEnv(readFileSync(resolved, 'utf8'));
66
+ if (!keepExisting) {
67
+ Object.assign(process.env, parsed);
68
+ return;
69
+ }
70
+ for (const [key, value] of Object.entries(parsed)) {
71
+ if (key in process.env)
72
+ continue;
73
+ process.env[key] = value;
74
+ }
59
75
  }
60
76
  static recommendedModels() {
61
77
  ConfigParser.recommended ||= JSON.parse(readFileSync(new URL('../models.json', import.meta.url), 'utf8'));
@@ -68,7 +84,8 @@ export class ConfigParser {
68
84
  return ConfigParser.instance;
69
85
  }
70
86
  async loadConfig(options) {
71
- if (this.config && !options?.config && !options?.path && this.runtimeBaseUrlOverride === (options?.baseUrl || null)) {
87
+ const target = options?.baseUrl || options?.from || null;
88
+ if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
72
89
  return this.config;
73
90
  }
74
91
  // Store the initial working directory for reference
@@ -81,6 +98,7 @@ export class ConfigParser {
81
98
  process.chdir(resolvedWorkingPath);
82
99
  }
83
100
  ConfigParser.loadEnv('.env');
101
+ ConfigParser.loadEnv(globalEnvPath(), true);
84
102
  try {
85
103
  const resolvedPath = options?.config || this.findConfigFile();
86
104
  let loadedConfig = null;
@@ -94,14 +112,22 @@ export class ConfigParser {
94
112
  log(`Configuration loaded from: ${resolvedPath}`);
95
113
  }
96
114
  if (!resolvedPath) {
97
- const outputRoot = resolveOutputRoot();
98
- loadedConfig = await this.buildEnvConfig(options?.baseUrl, outputRoot);
115
+ let envUrl = options?.baseUrl;
116
+ if (!envUrl && target?.startsWith('http'))
117
+ envUrl = target;
118
+ const outputRoot = resolveOutputRoot(process.env.EXPLORBOT_URL || envUrl);
119
+ loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
99
120
  sourcePath = join(outputRoot, 'explorbot.config.js');
100
121
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
101
122
  }
102
123
  this.config = this.resolveConfig(loadedConfig, options);
103
- this.runtimeBaseUrlOverride = options?.baseUrl || null;
124
+ await resolveConfigModels(this.config.ai);
125
+ this.runtimeTarget = target;
104
126
  this.configPath = sourcePath;
127
+ this.site = null;
128
+ if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
129
+ this.enterGlobalMode(this.config, target);
130
+ }
105
131
  // Restore original directory after successful config load
106
132
  if (options?.path && originalCwd !== process.cwd()) {
107
133
  process.chdir(originalCwd);
@@ -113,6 +139,8 @@ export class ConfigParser {
113
139
  if (options?.path && originalCwd !== process.cwd()) {
114
140
  process.chdir(originalCwd);
115
141
  }
142
+ if (error instanceof ConfigMissingError)
143
+ throw error;
116
144
  throw new Error(`Failed to load configuration: ${error}`);
117
145
  }
118
146
  }
@@ -127,22 +155,45 @@ export class ConfigParser {
127
155
  }
128
156
  getOutputDir() {
129
157
  const config = this.getConfig();
130
- const configPath = this.getConfigPath();
131
- if (!configPath)
158
+ if (!this.configPath)
132
159
  throw new Error('Config path not found');
133
- return path.join(path.dirname(configPath), config.dirs?.output || 'output');
160
+ return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
134
161
  }
135
162
  getProjectRoot() {
163
+ if (this.site)
164
+ return this.site.dir;
136
165
  const configPath = this.getConfigPath();
137
166
  if (configPath)
138
167
  return path.dirname(configPath);
139
168
  return process.cwd();
140
169
  }
141
170
  resolveProjectDir(relativeDir) {
142
- const configPath = this.getConfigPath();
143
- if (!configPath)
171
+ if (!this.configPath)
144
172
  return relativeDir;
145
- return path.join(path.dirname(configPath), relativeDir);
173
+ return path.join(this.getProjectRoot(), relativeDir);
174
+ }
175
+ isGlobalMode() {
176
+ return !!this.site;
177
+ }
178
+ getSite() {
179
+ return this.site;
180
+ }
181
+ resolveTargetPath(target) {
182
+ if (!this.site) {
183
+ const configured = this.config?.playwright?.url || this.config?.web?.url;
184
+ const targetOrigin = target ? URL.parse(target)?.origin : null;
185
+ const baseOrigin = configured ? URL.parse(configured)?.origin : null;
186
+ if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
187
+ 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.`);
188
+ }
189
+ return target || '/';
190
+ }
191
+ if (!target)
192
+ return this.siteStartPath;
193
+ const resolved = resolveSiteTarget(target, this.site.url);
194
+ if (resolved.baseUrl !== this.site.url)
195
+ return target;
196
+ return resolved.path;
146
197
  }
147
198
  getStatesDir() {
148
199
  return outputPath('states');
@@ -159,7 +210,9 @@ export class ConfigParser {
159
210
  if (ConfigParser.instance) {
160
211
  ConfigParser.instance.config = null;
161
212
  ConfigParser.instance.configPath = null;
162
- ConfigParser.instance.runtimeBaseUrlOverride = null;
213
+ ConfigParser.instance.runtimeTarget = null;
214
+ ConfigParser.instance.site = null;
215
+ ConfigParser.instance.siteStartPath = '/';
163
216
  }
164
217
  }
165
218
  // For testing purposes only - sets up minimal default config
@@ -206,11 +259,19 @@ export class ConfigParser {
206
259
  // Ignore cleanup errors
207
260
  }
208
261
  }
262
+ enterGlobalMode(config, target) {
263
+ const site = resolveSiteTarget(target || undefined, config.web?.url || config.playwright?.url);
264
+ this.site = registerSite(site.baseUrl);
265
+ this.siteStartPath = site.path;
266
+ config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
267
+ config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
268
+ log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
269
+ }
209
270
  async buildEnvConfig(baseUrl, outputRoot) {
210
271
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
211
272
  const modelSpec = process.env.EXPLORBOT_AI_MODEL;
212
273
  if (!provider && !modelSpec) {
213
- throw new Error('No configuration file found. Please create explorbot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
274
+ throw new ConfigMissingError(missingConfigMessage());
214
275
  }
215
276
  if (modelSpec && !provider && !modelSpec.includes('/')) {
216
277
  throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
@@ -244,11 +305,14 @@ export class ConfigParser {
244
305
  ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
245
306
  if (!agenticSpec && recommended.agenticModel)
246
307
  ai.agenticModel = await resolveModel(provider, 'agenticModel');
308
+ const dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
309
+ if (process.env.EXPLORBOT_OUTPUT)
310
+ dirs.output = '.';
247
311
  return {
248
312
  playwright: { browser: 'chromium', url, show: false },
249
313
  ai,
250
- dirs: { knowledge: 'knowledge', experience: 'experience', output: '.' },
251
- experience: { disabled: true },
314
+ dirs,
315
+ experience: { disabled: !!process.env.EXPLORBOT_EPHEMERAL },
252
316
  };
253
317
  }
254
318
  findConfigFile() {
@@ -259,13 +323,16 @@ export class ConfigParser {
259
323
  return fullPath;
260
324
  }
261
325
  }
262
- return null;
326
+ if (envConfigRequested())
327
+ return null;
328
+ return findGlobalConfig();
263
329
  }
264
330
  async loadConfigModule(configPath) {
265
331
  const ext = configPath.split('.').pop();
332
+ const moduleUrl = pathToFileURL(resolve(configPath)).href;
266
333
  if (ext === 'ts') {
267
334
  try {
268
- const module = await import(__rewriteRelativeImportExtension(configPath));
335
+ const module = await import(__rewriteRelativeImportExtension(moduleUrl));
269
336
  return module;
270
337
  }
271
338
  catch (error) {
@@ -274,7 +341,7 @@ export class ConfigParser {
274
341
  }
275
342
  }
276
343
  else if (ext === 'js' || ext === 'mjs') {
277
- const module = await import(__rewriteRelativeImportExtension(configPath));
344
+ const module = await import(__rewriteRelativeImportExtension(moduleUrl));
278
345
  return module;
279
346
  }
280
347
  else {
@@ -365,27 +432,77 @@ export async function resolveModel(spec, role = 'model') {
365
432
  }
366
433
  return createModel(spec, modelId);
367
434
  }
368
- export function resolveOutputRoot() {
435
+ export class ConfigMissingError extends Error {
436
+ }
437
+ export function envConfigRequested() {
438
+ return !!(process.env.EXPLORBOT_AI_PROVIDER || process.env.EXPLORBOT_AI_MODEL);
439
+ }
440
+ export function missingConfigMessage(configFile = 'explorbot.config.js') {
441
+ const cli = getCliName();
442
+ return dedent `
443
+ No AI configuration found. Set up explorbot in one of these ways:
444
+
445
+ Global - configure this machine once, then run from any directory:
446
+ ${cli} init --global
447
+
448
+ Local - create ${configFile} for this project:
449
+ ${cli} init
450
+
451
+ Environment - one-off run, no files written:
452
+ EXPLORBOT_AI_PROVIDER=openrouter EXPLORBOT_URL=https://your-app.example.com ${cli} ...
453
+
454
+ Providers: ${Object.keys(PROVIDERS).join(', ')}
455
+ `;
456
+ }
457
+ export async function resolveConfigModels(ai) {
458
+ if (!ai)
459
+ return;
460
+ const roles = ['model', 'visionModel', 'agenticModel'];
461
+ for (const role of roles) {
462
+ if (typeof ai[role] === 'string')
463
+ ai[role] = await resolveModel(ai[role], role);
464
+ }
465
+ for (const agent of Object.values(ai.agents || {})) {
466
+ if (typeof agent?.model === 'string')
467
+ agent.model = await resolveModel(agent.model);
468
+ }
469
+ }
470
+ export function resolveOutputRoot(baseUrl) {
369
471
  if (cachedOutputRoot)
370
472
  return cachedOutputRoot;
371
473
  const configured = process.env.EXPLORBOT_OUTPUT;
372
- if (!configured) {
373
- cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
474
+ if (configured) {
475
+ cachedOutputRoot = resolve(configured);
476
+ mkdirSync(cachedOutputRoot, { recursive: true });
477
+ return cachedOutputRoot;
478
+ }
479
+ if (baseUrl) {
480
+ cachedOutputRoot = resolveStateRoot(baseUrl, !!process.env.EXPLORBOT_EPHEMERAL);
374
481
  return cachedOutputRoot;
375
482
  }
376
- cachedOutputRoot = resolve(configured);
377
- mkdirSync(cachedOutputRoot, { recursive: true });
483
+ cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
378
484
  return cachedOutputRoot;
379
485
  }
486
+ export function resolveStateRoot(baseUrl, ephemeral) {
487
+ const url = URL.parse(baseUrl);
488
+ if (ephemeral || !url?.host)
489
+ return mkdtempSync(join(tmpdir(), 'explorbot-'));
490
+ return registerSite(url.origin).dir;
491
+ }
380
492
  export function materializeKnowledge(outputRoot) {
381
493
  const inline = process.env.EXPLORBOT_KNOWLEDGE;
382
494
  const knowledgeFile = process.env.EXPLORBOT_KNOWLEDGE_FILE;
495
+ const knowledgeDir = join(outputRoot, 'knowledge');
496
+ const globalFile = join(knowledgeDir, 'global.md');
497
+ const envDir = join(knowledgeDir, 'env');
498
+ if (!inline)
499
+ rmSync(globalFile, { force: true });
500
+ rmSync(envDir, { recursive: true, force: true });
383
501
  if (!inline && !knowledgeFile)
384
502
  return;
385
- const knowledgeDir = join(outputRoot, 'knowledge');
386
503
  mkdirSync(knowledgeDir, { recursive: true });
387
504
  if (inline) {
388
- writeFileSync(join(knowledgeDir, 'global.md'), matter.stringify(inline, { url: '*', endpoint: '*' }));
505
+ writeFileSync(globalFile, matter.stringify(inline, { url: '*', endpoint: '*' }));
389
506
  }
390
507
  if (!knowledgeFile)
391
508
  return;
@@ -393,12 +510,13 @@ export function materializeKnowledge(outputRoot) {
393
510
  if (!existsSync(source)) {
394
511
  throw new Error(`Knowledge file from EXPLORBOT_KNOWLEDGE_FILE not found: ${source}`);
395
512
  }
396
- copyFileSync(source, join(knowledgeDir, basename(source)));
513
+ mkdirSync(envDir, { recursive: true });
514
+ copyFileSync(source, join(envDir, basename(source)));
397
515
  }
398
516
  export async function createModel(provider, modelId) {
399
- const factory = PROVIDERS[provider];
400
- if (!factory) {
517
+ const info = PROVIDERS[provider];
518
+ if (!info) {
401
519
  throw new Error(`Unknown AI provider "${provider}". Supported providers: ${Object.keys(PROVIDERS).join(', ')}`);
402
520
  }
403
- return (await factory())(modelId);
521
+ return (await info.load())(modelId);
404
522
  }
@@ -10,6 +10,8 @@ export declare class ExecutionController extends EventEmitter {
10
10
  constructor();
11
11
  static getInstance(): ExecutionController;
12
12
  setInputCallback(callback: InputCallback): void;
13
+ hasInputCallback(): boolean;
14
+ clearInputCallback(): void;
13
15
  startExecution(): void;
14
16
  getAbortSignal(): AbortSignal | undefined;
15
17
  interrupt(): void;
@@ -20,6 +20,12 @@ export class ExecutionController extends EventEmitter {
20
20
  setInputCallback(callback) {
21
21
  this.inputCallback = callback;
22
22
  }
23
+ hasInputCallback() {
24
+ return !!this.inputCallback;
25
+ }
26
+ clearInputCallback() {
27
+ this.inputCallback = null;
28
+ }
23
29
  startExecution() {
24
30
  this.interrupted = false;
25
31
  this.abortController = new AbortController();
@@ -1,3 +1,4 @@
1
+ import type { Browser } from 'playwright';
1
2
  import type { AgentDeps } from './ai/agent.js';
2
3
  import { Captain } from './ai/captain.js';
3
4
  import { Driller } from './ai/driller.js';
@@ -14,7 +15,7 @@ import { Researcher } from './ai/researcher.js';
14
15
  import { SessionAnalyst } from './ai/session-analyst.js';
15
16
  import { Tester } from './ai/tester.js';
16
17
  import { RequestStore } from './api/request-store.js';
17
- import type { ExplorbotConfig } from './config.js';
18
+ import type { ExplorbotConfig, ReporterConfig } from './config.js';
18
19
  import { ConfigParser } from './config.js';
19
20
  import { ExperienceTracker } from './experience-tracker.js';
20
21
  import Explorer from './explorer.js';
@@ -34,6 +35,11 @@ export interface ExplorBotOptions {
34
35
  headless?: boolean;
35
36
  incognito?: boolean;
36
37
  session?: string | boolean;
38
+ instance?: string;
39
+ optionalAi?: boolean;
40
+ reporter?: ReporterConfig;
41
+ attachedBrowser?: Browser;
42
+ applicationSpec?: string;
37
43
  }
38
44
  export type UserResolveFunction = (error?: Error, showWelcome?: boolean) => Promise<string | null>;
39
45
  export declare class ExplorBot {
@@ -47,6 +53,7 @@ export declare class ExplorBot {
47
53
  planFeature?: string;
48
54
  lastPlanError: Error | null;
49
55
  lastSavedPlanPath: string | null;
56
+ aiFailure: string | null;
50
57
  agents: Record<string, any>;
51
58
  sessionPlans: Plan[];
52
59
  lastReportedTestCount: number;
@@ -59,8 +66,10 @@ export declare class ExplorBot {
59
66
  constructor(options?: ExplorBotOptions);
60
67
  get isExploring(): boolean;
61
68
  setUserResolve(fn: UserResolveFunction): void;
69
+ attachBrowser(browser: Browser): void;
62
70
  start(): Promise<void>;
63
71
  startProviderOnly(): Promise<void>;
72
+ aiFailureReason(): string | null;
64
73
  stop(): Promise<void>;
65
74
  visitInitialState(): Promise<void>;
66
75
  visit(url: string): Promise<void>;
@@ -112,4 +121,5 @@ export declare class ExplorBot {
112
121
  getSessionTests(): Test[];
113
122
  printSessionAnalysis(): Promise<void>;
114
123
  isHistorianEnabled(): boolean;
124
+ bootstrapOptionalProvider(): Promise<void>;
115
125
  }
@@ -43,6 +43,7 @@ export class ExplorBot {
43
43
  planFeature;
44
44
  lastPlanError = null;
45
45
  lastSavedPlanPath = null;
46
+ aiFailure = null;
46
47
  agents = {};
47
48
  sessionPlans = [];
48
49
  lastReportedTestCount = 0;
@@ -66,6 +67,9 @@ export class ExplorBot {
66
67
  setUserResolve(fn) {
67
68
  this.userResolveFn = fn;
68
69
  }
70
+ attachBrowser(browser) {
71
+ this.options.attachedBrowser = browser;
72
+ }
69
73
  async start() {
70
74
  if (this.explorer) {
71
75
  return;
@@ -81,7 +85,7 @@ export class ExplorBot {
81
85
  playwrightRecorder: this.playwrightRecorder(),
82
86
  });
83
87
  await this.explorer.start();
84
- if (!this.options.incognito) {
88
+ if (!this.options.incognito && this.provider) {
85
89
  await this.agentExperienceCompactor().autocompact();
86
90
  }
87
91
  }
@@ -97,19 +101,25 @@ export class ExplorBot {
97
101
  this.config = await this.configParser.loadConfig(this.options);
98
102
  if (this.options.session === true)
99
103
  this.options.session = path.join(this.configParser.getOutputDir(), 'session.json');
104
+ if (typeof this.options.session === 'string')
105
+ this.options.session = path.resolve(this.options.path || '.', this.options.session);
106
+ if (this.options.optionalAi)
107
+ return this.bootstrapOptionalProvider();
100
108
  this.provider = new AIProvider(this.config.ai);
101
109
  await this.provider.validateConnection();
102
110
  }
111
+ aiFailureReason() {
112
+ return this.aiFailure;
113
+ }
103
114
  async stop() {
104
115
  this.agents.quartermaster?.stop();
105
116
  await this.explorer?.stop();
106
117
  }
107
118
  async visitInitialState() {
108
- const url = this.options.from || '/';
109
- await this.visit(url);
119
+ await this.visit(this.options.from || '/');
110
120
  }
111
121
  async visit(url) {
112
- return this.agentNavigator().visit(url);
122
+ return this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
113
123
  }
114
124
  async openTab() {
115
125
  await this.explorer.openTab();
@@ -121,7 +131,7 @@ export class ExplorBot {
121
131
  return this.explorer;
122
132
  }
123
133
  knowledgeTracker() {
124
- return (this._knowledgeTracker ||= new KnowledgeTracker());
134
+ return (this._knowledgeTracker ||= new KnowledgeTracker(this.options.applicationSpec));
125
135
  }
126
136
  experienceTracker() {
127
137
  return (this._experienceTracker ||= new ExperienceTracker(this.knowledgeTracker()));
@@ -130,7 +140,7 @@ export class ExplorBot {
130
140
  return (this._stateManager ||= new StateManager(this.experienceTracker(), this.knowledgeTracker()));
131
141
  }
132
142
  reporter() {
133
- return (this._reporter ||= new Reporter(this.config.reporter, this.stateManager()));
143
+ return (this._reporter ||= new Reporter(this.options.reporter || this.config.reporter, this.stateManager()));
134
144
  }
135
145
  requestStore() {
136
146
  return (this._requestStore ||= new RequestStore(this.configParser.getOutputDir()));
@@ -451,4 +461,15 @@ export class ExplorBot {
451
461
  isHistorianEnabled() {
452
462
  return this.config.ai?.agents?.historian?.enabled !== false;
453
463
  }
464
+ async bootstrapOptionalProvider() {
465
+ try {
466
+ const provider = new AIProvider(this.config.ai);
467
+ await provider.validateConnection();
468
+ this.provider = provider;
469
+ }
470
+ catch (error) {
471
+ this.aiFailure = browserErrorMessage(error);
472
+ tag('debug').log(`AI provider unavailable: ${this.aiFailure}`);
473
+ }
474
+ }
454
475
  }
@@ -1,4 +1,4 @@
1
- import type { BrowserContextOptions, Page } from 'playwright';
1
+ import type { Browser, BrowserContextOptions, Page } from 'playwright';
2
2
  import { ActionResult } from './action-result.js';
3
3
  import Action from './action.js';
4
4
  import type { RequestStore } from './api/request-store.js';
@@ -56,6 +56,7 @@ declare class Explorer {
56
56
  initializeContainer(): void;
57
57
  convertToCodeceptConfig(config: ExplorbotConfig): any;
58
58
  connectOrLaunchBrowser(): Promise<void>;
59
+ openContextPage(): Promise<void>;
59
60
  createBrowserContextOptions(): BrowserContextOptions;
60
61
  attachXhrCapture(): void;
61
62
  runWithRecovery<T>(label: string, operation: () => Promise<T>): Promise<T>;
@@ -81,6 +82,8 @@ export interface ExplorerOptions {
81
82
  headless?: boolean;
82
83
  incognito?: boolean;
83
84
  session?: string;
85
+ instance?: string;
86
+ attachedBrowser?: Browser;
84
87
  }
85
88
  export interface ExplorerDeps {
86
89
  stateManager: StateManager;
@@ -10,7 +10,6 @@ import { ActionResult } from "./action-result.js";
10
10
  import Action from './action.js';
11
11
  import { XhrCapture } from "./api/xhr-capture.js";
12
12
  import { ConfigParser } from './config.js';
13
- import { TestResult } from "./test-plan.js";
14
13
  import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
15
14
  import { createDebug, log, tag } from './utils/logger.js';
16
15
  import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
@@ -74,8 +73,8 @@ class Explorer {
74
73
  throw new Error('Playwright helper not available');
75
74
  }
76
75
  await this.connectOrLaunchBrowser();
77
- const hasSession = this.options?.session && existsSync(this.options.session);
78
- await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
76
+ const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
77
+ await this.openContextPage();
79
78
  await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
80
79
  this.attachXhrCapture();
81
80
  if (hasSession) {
@@ -91,8 +90,9 @@ class Explorer {
91
90
  if (!this.started)
92
91
  return;
93
92
  this.started = false;
93
+ const attached = !!this.options?.attachedBrowser;
94
94
  await this.stopCaptures();
95
- if (this.options?.session && this.playwrightHelper?.browserContext) {
95
+ if (!attached && this.options?.session && this.playwrightHelper?.browserContext) {
96
96
  const dir = path.dirname(this.options.session);
97
97
  if (!existsSync(dir))
98
98
  mkdirSync(dir, { recursive: true });
@@ -105,7 +105,14 @@ class Explorer {
105
105
  await Promise.all([this.reporter.finishRun(), this.playwrightHelper._stopBrowser(), codeceptjs.recorder.stop()]);
106
106
  return;
107
107
  }
108
- tag('info').log('Closing browser context (persistent browser stays running)');
108
+ if (attached) {
109
+ tag('info').log('Disconnecting from attached browser (its pages stay open)');
110
+ await this.playwrightHelper.browser?.close().catch((err) => {
111
+ debugLog('Failed to disconnect from attached browser:', err);
112
+ });
113
+ }
114
+ if (!attached)
115
+ tag('info').log('Closing browser context (persistent browser stays running)');
109
116
  await this.closeBrowserContext();
110
117
  this.playwrightHelper.browser = null;
111
118
  this.playwrightHelper.isRunning = false;
@@ -247,8 +254,15 @@ class Explorer {
247
254
  return codeceptConfig;
248
255
  }
249
256
  async connectOrLaunchBrowser() {
257
+ if (this.options?.attachedBrowser) {
258
+ this.playwrightHelper.browser = this.options.attachedBrowser;
259
+ this.playwrightHelper.isRunning = true;
260
+ this.isSharedBrowser = true;
261
+ tag('success').log('Attached to a browser opened by another tool');
262
+ return;
263
+ }
250
264
  const { getAliveEndpoint } = await import('./browser-server.js');
251
- const endpoint = await getAliveEndpoint();
265
+ const endpoint = await getAliveEndpoint(this.options?.instance);
252
266
  if (endpoint) {
253
267
  const browserName = this.config.playwright.browser || 'chromium';
254
268
  this.playwrightHelper.options[browserName] ||= {};
@@ -261,6 +275,19 @@ class Explorer {
261
275
  }
262
276
  await this.playwrightHelper._startBrowser();
263
277
  }
278
+ async openContextPage() {
279
+ const attached = this.options?.attachedBrowser;
280
+ if (!attached) {
281
+ await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
282
+ return;
283
+ }
284
+ const context = attached.contexts()[0] || (await attached.newContext());
285
+ const pages = context.pages();
286
+ const page = pages[pages.length - 1] || (await context.newPage());
287
+ this.playwrightHelper.browserContext = context;
288
+ await this.playwrightHelper._setPage(page);
289
+ debugLog(`Adopted attached browser page: ${page.url()}`);
290
+ }
264
291
  createBrowserContextOptions() {
265
292
  const helperOptions = this.playwrightHelper.options || {};
266
293
  const contextOptions = {
@@ -427,6 +454,10 @@ class Explorer {
427
454
  await this.playwrightRecorder.stop();
428
455
  }
429
456
  async closeBrowserContext() {
457
+ if (this.options?.attachedBrowser) {
458
+ this.playwrightHelper.browserContext = null;
459
+ return;
460
+ }
430
461
  if (!this.playwrightHelper.browserContext)
431
462
  return;
432
463
  await this.playwrightHelper.browserContext.close().catch((err) => {
@@ -447,7 +478,7 @@ class Explorer {
447
478
  });
448
479
  }
449
480
  await this.connectOrLaunchBrowser();
450
- await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
481
+ await this.openContextPage();
451
482
  await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
452
483
  this.attachXhrCapture();
453
484
  this.listenToStateChanged();
@@ -542,6 +573,8 @@ class Explorer {
542
573
  async closeOtherTabs() {
543
574
  if (!this.playwrightHelper)
544
575
  return;
576
+ if (this.options?.attachedBrowser)
577
+ return;
545
578
  const context = this.playwrightHelper.page.context();
546
579
  const pages = context.pages();
547
580
  if (pages.length <= 1)
@@ -590,12 +623,12 @@ class Explorer {
590
623
  if (this.observedTestPages.has(page))
591
624
  return;
592
625
  this.testPageErrorHandler ||= (err) => {
593
- this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
626
+ this._activeTest?.addObservation(`Console error: ${err.message}`);
594
627
  };
595
628
  this.testConsoleHandler ||= (msg) => {
596
629
  if (msg.type() !== 'error')
597
630
  return;
598
- this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
631
+ this._activeTest?.addObservation(`Console error: ${msg.text()}`);
599
632
  };
600
633
  this.testDialogHandler ||= (dialog) => {
601
634
  const dialogType = dialog.type();