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