explorbot 0.2.1 → 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 (115) 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/ai/documentarian.ts +3 -0
  7. package/boat/doc-collector/src/ai/tools.ts +17 -5
  8. package/boat/doc-collector/src/cli.ts +2 -0
  9. package/boat/doc-collector/src/config.ts +2 -0
  10. package/boat/doc-collector/src/docbot.ts +39 -11
  11. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  12. package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
  13. package/boat/doc-collector/src/screenshots.ts +22 -14
  14. package/boat/doc-collector/src/state-diagram.ts +61 -14
  15. package/boat/prima/bin/prima-cli.ts +5 -0
  16. package/boat/prima/package.json +16 -0
  17. package/boat/prima/src/cli.ts +222 -0
  18. package/boat/prima/src/envelope.ts +141 -0
  19. package/boat/prima/src/prima.ts +705 -0
  20. package/boat/prima/src/pw-parser.ts +17 -0
  21. package/boat/prima/src/pw-registry.ts +75 -0
  22. package/dist/bin/explorbot-cli.js +44 -31
  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 +36 -8
  26. package/dist/boat/doc-collector/src/ai/documentarian.js +3 -0
  27. package/dist/boat/doc-collector/src/ai/tools.js +10 -4
  28. package/dist/boat/doc-collector/src/cli.js +2 -0
  29. package/dist/boat/doc-collector/src/config.js +1 -0
  30. package/dist/boat/doc-collector/src/docbot.js +36 -10
  31. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  32. package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
  33. package/dist/boat/doc-collector/src/screenshots.js +23 -14
  34. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  35. package/dist/boat/prima/bin/prima-cli.js +4 -0
  36. package/dist/boat/prima/src/cli.js +200 -0
  37. package/dist/boat/prima/src/envelope.js +116 -0
  38. package/dist/boat/prima/src/prima.js +635 -0
  39. package/dist/boat/prima/src/pw-parser.js +18 -0
  40. package/dist/boat/prima/src/pw-registry.js +66 -0
  41. package/dist/models.json +3 -0
  42. package/dist/package.json +9 -2
  43. package/dist/src/action.d.ts +5 -2
  44. package/dist/src/action.js +5 -5
  45. package/dist/src/ai/captain/mixin.js +3 -4
  46. package/dist/src/ai/captain/web-mode.js +1 -1
  47. package/dist/src/ai/navigator.d.ts +4 -0
  48. package/dist/src/ai/navigator.js +11 -6
  49. package/dist/src/ai/planner.d.ts +1 -0
  50. package/dist/src/ai/planner.js +6 -0
  51. package/dist/src/ai/researcher/locators.js +1 -1
  52. package/dist/src/ai/researcher.js +1 -1
  53. package/dist/src/ai/task-agent.js +1 -1
  54. package/dist/src/ai/tester.d.ts +1 -0
  55. package/dist/src/ai/tester.js +13 -0
  56. package/dist/src/application-spec-contract.d.ts +8 -0
  57. package/dist/src/application-spec-contract.js +8 -0
  58. package/dist/src/application-spec.d.ts +15 -0
  59. package/dist/src/application-spec.js +71 -0
  60. package/dist/src/browser-server.d.ts +12 -6
  61. package/dist/src/browser-server.js +74 -19
  62. package/dist/src/commands/clean-command.js +2 -7
  63. package/dist/src/commands/init-command.d.ts +5 -0
  64. package/dist/src/commands/init-command.js +119 -1
  65. package/dist/src/commands/navigate-command.js +1 -1
  66. package/dist/src/commands/research-command.js +1 -1
  67. package/dist/src/commands/sites-command.d.ts +6 -0
  68. package/dist/src/commands/sites-command.js +23 -0
  69. package/dist/src/components/InitWizard.d.ts +10 -0
  70. package/dist/src/components/InitWizard.js +133 -0
  71. package/dist/src/components/InputReadline.d.ts +1 -0
  72. package/dist/src/components/InputReadline.js +7 -4
  73. package/dist/src/config.d.ts +24 -5
  74. package/dist/src/config.js +146 -37
  75. package/dist/src/explorbot.d.ts +9 -0
  76. package/dist/src/explorbot.js +24 -5
  77. package/dist/src/explorer.d.ts +5 -1
  78. package/dist/src/explorer.js +54 -19
  79. package/dist/src/global-config.d.ts +22 -0
  80. package/dist/src/global-config.js +117 -0
  81. package/dist/src/knowledge-tracker.d.ts +5 -1
  82. package/dist/src/knowledge-tracker.js +14 -1
  83. package/dist/src/utils/cli-name.js +6 -2
  84. package/dist/src/utils/test-files.js +1 -2
  85. package/dist/src/utils/url-matcher.d.ts +1 -0
  86. package/dist/src/utils/url-matcher.js +9 -0
  87. package/models.json +3 -0
  88. package/package.json +9 -2
  89. package/src/action.ts +9 -5
  90. package/src/ai/captain/mixin.ts +3 -3
  91. package/src/ai/captain/web-mode.ts +1 -1
  92. package/src/ai/navigator.ts +12 -7
  93. package/src/ai/planner.ts +7 -0
  94. package/src/ai/researcher/locators.ts +1 -1
  95. package/src/ai/researcher.ts +1 -1
  96. package/src/ai/task-agent.ts +1 -1
  97. package/src/ai/tester.ts +15 -0
  98. package/src/application-spec-contract.ts +10 -0
  99. package/src/application-spec.ts +87 -0
  100. package/src/browser-server.ts +74 -19
  101. package/src/commands/clean-command.ts +1 -6
  102. package/src/commands/init-command.ts +146 -1
  103. package/src/commands/navigate-command.ts +1 -1
  104. package/src/commands/research-command.ts +1 -1
  105. package/src/commands/sites-command.ts +27 -0
  106. package/src/components/InitWizard.tsx +166 -0
  107. package/src/components/InputReadline.tsx +8 -4
  108. package/src/config.ts +162 -39
  109. package/src/explorbot.ts +30 -5
  110. package/src/explorer.ts +59 -20
  111. package/src/global-config.ts +148 -0
  112. package/src/knowledge-tracker.ts +17 -1
  113. package/src/utils/cli-name.ts +5 -2
  114. package/src/utils/test-files.ts +1 -2
  115. package/src/utils/url-matcher.ts +10 -0
package/README.md CHANGED
@@ -138,7 +138,7 @@ EXPLORBOT_KNOWLEDGE="Log in as admin@example.com / secret123" \
138
138
  npx explorbot explore /admin/users --max-tests 3
139
139
  ```
140
140
 
141
- Output lands in a temp directory and nothing is written to your project. See [Agentic Usage](docs/workflow/agentic-usage.md).
141
+ Output lands in a per-host state directory, `~/.explorbot/state/<host>/`, so runs against the same app collect in one place and nothing is written to your project. Set `EXPLORBOT_EPHEMERAL=1` to keep nothing between runs. See [Agentic Usage](docs/workflow/agentic-usage.md).
142
142
 
143
143
  ## Teaching Explorbot
144
144
 
@@ -40,6 +40,7 @@ interface CLIOptions {
40
40
  headless?: boolean;
41
41
  incognito?: boolean;
42
42
  session?: string | boolean;
43
+ spec?: string;
43
44
  }
44
45
 
45
46
  function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
@@ -52,6 +53,7 @@ function buildExplorBotOptions(from: string | undefined, options: CLIOptions): E
52
53
  headless: options.headless,
53
54
  incognito: options.incognito,
54
55
  session: options.session,
56
+ applicationSpec: options.spec,
55
57
  } as ExplorBotOptions;
56
58
  }
57
59
 
@@ -64,6 +66,7 @@ function addCommonOptions(cmd: Command): Command {
64
66
  .option('-s, --show', 'Show browser window')
65
67
  .option('--headless', 'Run browser in headless mode')
66
68
  .option('--incognito', 'Run without recording experiences')
69
+ .option('--spec <path>', 'Use a Docbot application spec directory or index.md')
67
70
  .option('--session [file]', 'Save/restore browser session from file');
68
71
  }
69
72
 
@@ -386,6 +389,14 @@ program
386
389
  }
387
390
  });
388
391
 
392
+ program
393
+ .command('sites')
394
+ .description('List sites registered in the global installation')
395
+ .action(async () => {
396
+ const { SitesCommand } = await import('../src/commands/sites-command.js');
397
+ await new SitesCommand(new ExplorBot()).execute('');
398
+ });
399
+
389
400
  addCommonOptions(program.command('rerun <filename> [index]').description('Re-run generated tests with AI auto-healing')).action(async (filename, index, options) => {
390
401
  try {
391
402
  const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
@@ -421,17 +432,28 @@ addCommonOptions(
421
432
 
422
433
  program
423
434
  .command('init')
424
- .description('Initialize a new project with configuration')
425
- .option('-c, --config-path <path>', 'Path for the config file', './explorbot.config.js')
435
+ .description('Initialize configuration for a project or for this machine')
436
+ .option('-c, --config-path <path>', 'Path for the config file')
426
437
  .option('-f, --force', 'Overwrite existing config file')
427
438
  .option('-p, --path <path>', 'Working directory for initialization')
439
+ .option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
440
+ .option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
441
+ .option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
428
442
  .action(async (options) => {
429
- const { runInitCommand } = await import('../src/commands/init-command.js');
430
- runInitCommand({
431
- configPath: options.configPath,
432
- force: options.force,
433
- path: options.path,
434
- });
443
+ try {
444
+ const { runInit } = await import('../src/commands/init-command.js');
445
+ await runInit({
446
+ configPath: options.configPath,
447
+ force: options.force,
448
+ path: options.path,
449
+ global: options.global,
450
+ provider: options.provider,
451
+ apiKey: options.apiKey,
452
+ });
453
+ } catch (error) {
454
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
455
+ process.exit(1);
456
+ }
435
457
  });
436
458
 
437
459
  program
@@ -694,7 +716,7 @@ program
694
716
  const explorBot = new ExplorBot(mainOptions);
695
717
  await explorBot.start();
696
718
 
697
- await explorBot.agentNavigator().visit(url);
719
+ await explorBot.visit(url);
698
720
 
699
721
  const { ContextCommand } = await import('../src/commands/context-command.js');
700
722
  const argParts: string[] = [];
@@ -716,7 +738,7 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
716
738
  try {
717
739
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
718
740
  await explorBot.start();
719
- await explorBot.agentNavigator().visit(url);
741
+ await explorBot.visit(url);
720
742
 
721
743
  const action = explorBot.getExplorer().action();
722
744
  await action.execute(command);
@@ -740,10 +762,11 @@ browserCmd
740
762
  .description('Launch a persistent browser server')
741
763
  .option('-s, --show', 'Launch browser in headed mode (visible window)')
742
764
  .option('--headless', 'Launch browser in headless mode')
765
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
743
766
  .option('-c, --config <path>', 'Path to configuration file')
744
767
  .option('-p, --path <path>', 'Working directory path')
745
768
  .action(async (options) => {
746
- const { launchServer, removeEndpointFile } = await import('../src/browser-server.js');
769
+ const { launchServer, removeEndpointFile, keepServerRunning } = await import('../src/browser-server.js');
747
770
  await ConfigParser.getInstance().loadConfig({
748
771
  config: options.config,
749
772
  path: options.path,
@@ -754,55 +777,45 @@ browserCmd
754
777
  if (options.show !== undefined) show = true;
755
778
  if (options.headless !== undefined) show = false;
756
779
 
757
- const server = await launchServer({
758
- browser: config.playwright.browser,
759
- show,
760
- });
761
-
762
- console.log('Browser server is running. Press Ctrl+C to stop.');
763
-
764
- const cleanup = () => {
765
- console.log('\nStopping browser server...');
766
- server.close();
767
- removeEndpointFile();
768
- process.exit(0);
769
- };
780
+ const server = await launchServer(
781
+ {
782
+ browser: config.playwright.browser,
783
+ show,
784
+ },
785
+ options.instance
786
+ );
770
787
 
771
- process.on('SIGINT', cleanup);
772
- process.on('SIGTERM', cleanup);
788
+ await keepServerRunning(async () => {
789
+ await server.close();
790
+ removeEndpointFile(options.instance);
791
+ });
773
792
  });
774
793
 
775
794
  browserCmd
776
795
  .command('stop')
777
796
  .description('Stop a running browser server')
797
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
778
798
  .option('-c, --config <path>', 'Path to configuration file')
779
799
  .option('-p, --path <path>', 'Working directory path')
780
800
  .action(async (options) => {
781
- const { getAliveEndpoint, removeEndpointFile } = await import('../src/browser-server.js');
801
+ const { stopServer } = await import('../src/browser-server.js');
782
802
  await ConfigParser.getInstance().loadConfig({
783
803
  config: options.config,
784
804
  path: options.path,
785
805
  });
786
806
 
787
- const endpoint = await getAliveEndpoint();
788
- if (!endpoint) {
807
+ if (!(await stopServer(options.instance))) {
789
808
  console.log('No running browser server found.');
790
809
  process.exit(0);
791
810
  }
792
811
 
793
- try {
794
- const { chromium } = await import('playwright-core');
795
- const browser = await chromium.connect(endpoint, { timeout: 3000 });
796
- await browser.close();
797
- } catch {}
798
-
799
- removeEndpointFile();
800
812
  console.log('Browser server stopped.');
801
813
  });
802
814
 
803
815
  browserCmd
804
816
  .command('status')
805
817
  .description('Check if a browser server is running')
818
+ .option('--instance <name>', 'Named browser instance (lowercase letters, digits, dashes)')
806
819
  .option('-c, --config <path>', 'Path to configuration file')
807
820
  .option('-p, --path <path>', 'Working directory path')
808
821
  .action(async (options) => {
@@ -812,7 +825,7 @@ browserCmd
812
825
  path: options.path,
813
826
  });
814
827
 
815
- const endpoint = await getAliveEndpoint();
828
+ const endpoint = await getAliveEndpoint(options.instance);
816
829
  if (endpoint) {
817
830
  console.log(`Browser server is running at: ${endpoint}`);
818
831
  } else {
@@ -871,8 +884,10 @@ program
871
884
 
872
885
  import { createApiCommands } from '../boat/api-tester/src/cli.ts';
873
886
  import { createDocsCommands } from '../boat/doc-collector/src/cli.ts';
887
+ import { createPrimaCommands } from '../boat/prima/src/cli.ts';
874
888
  program.addCommand(createApiCommands('api'));
875
889
  program.addCommand(createDocsCommands('docs'));
890
+ program.addCommand(createPrimaCommands('prima'));
876
891
 
877
892
  const envHelp = () => {
878
893
  const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
@@ -33,7 +33,7 @@ export class ApiBot {
33
33
  }
34
34
 
35
35
  async start(): Promise<void> {
36
- this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path });
36
+ this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path, endpoint: this.options.endpoint });
37
37
  this.provider = new AIProvider(this.config.ai);
38
38
  await this.provider.validateConnection();
39
39
 
@@ -101,12 +101,13 @@ export class ApiBot {
101
101
  return (this.agents.curler ||= this.createAgent(({ ai, apiClient, requestState }) => new Curler(ai, apiClient, requestState, this.reporter)));
102
102
  }
103
103
 
104
- async plan(endpoint: string, opts: { style?: string; fresh?: boolean } = {}): Promise<Plan> {
104
+ async plan(target: string, opts: { style?: string; fresh?: boolean } = {}): Promise<Plan> {
105
105
  if (opts.fresh) {
106
106
  this.currentPlan = undefined;
107
107
  this.agents.chief = undefined;
108
108
  }
109
109
 
110
+ const endpoint = this.configParser.resolveEndpointPath(target);
110
111
  const chief = this.agentChief();
111
112
  const specDefinition = this.getEndpointDefinition(endpoint);
112
113
  this.currentPlan = await chief.plan(endpoint, { style: opts.style, specDefinition });
@@ -198,6 +199,7 @@ interface ApibotOptions {
198
199
  verbose?: boolean;
199
200
  config?: string;
200
201
  path?: string;
202
+ endpoint?: string;
201
203
  }
202
204
 
203
205
  export type { ApibotOptions };
@@ -49,7 +49,7 @@ export function createApiCommands(name = 'api'): Command {
49
49
  addCommonOptions(cmd.command('plan <endpoint>').description('Generate test plan for an API endpoint').option('--style <style>', 'Planning style: basename of a file in rules/chief/styles/').option('--fresh', 'Start planning from scratch')).action(async (endpoint, options) => {
50
50
  setPreserveConsoleLogs(true);
51
51
  try {
52
- const bot = new ApiBot(buildOptions(options));
52
+ const bot = new ApiBot({ ...buildOptions(options), endpoint });
53
53
  await bot.start();
54
54
 
55
55
  await bot.plan(endpoint, { style: options.style, fresh: options.fresh });
@@ -122,7 +122,7 @@ export function createApiCommands(name = 'api'): Command {
122
122
  addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan')).action(async (endpoint, options) => {
123
123
  setPreserveConsoleLogs(true);
124
124
  try {
125
- const bot = new ApiBot(buildOptions(options));
125
+ const bot = new ApiBot({ ...buildOptions(options), endpoint });
126
126
  await bot.start();
127
127
 
128
128
  const styles = Object.keys(getStyles());
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path, { resolve } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
- import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from '../../../src/config.ts';
4
+ import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot } from '../../../src/config.ts';
5
+ import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
5
6
 
6
7
  export type { AIConfig };
7
8
 
@@ -26,6 +27,7 @@ export class ApibotConfigParser {
26
27
  private static instance: ApibotConfigParser;
27
28
  private config: ApibotConfig | null = null;
28
29
  private configPath: string | null = null;
30
+ private site: SiteRecord | null = null;
29
31
 
30
32
  private constructor() {}
31
33
 
@@ -42,7 +44,7 @@ export class ApibotConfigParser {
42
44
  Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
43
45
  }
44
46
 
45
- async loadConfig(options?: { config?: string; path?: string }): Promise<ApibotConfig> {
47
+ async loadConfig(options?: { config?: string; path?: string; endpoint?: string }): Promise<ApibotConfig> {
46
48
  if (this.config && !options?.config && !options?.path) return this.config;
47
49
 
48
50
  const originalCwd = process.cwd();
@@ -50,6 +52,7 @@ export class ApibotConfigParser {
50
52
  process.chdir(resolve(options.path));
51
53
  }
52
54
 
55
+ ApibotConfigParser.loadEnv(globalEnvPath());
53
56
  ApibotConfigParser.loadEnv('.env');
54
57
 
55
58
  const resolvedPath = options?.config || this.findConfigFile();
@@ -78,7 +81,14 @@ export class ApibotConfigParser {
78
81
  }
79
82
 
80
83
  this.config = this.mergeWithDefaults(loadedConfig);
84
+ await resolveConfigModels(this.config.ai);
81
85
  this.configPath = resolvedPath;
86
+ this.site = null;
87
+
88
+ if (isGlobalConfigPath(resolvedPath)) {
89
+ this.enterGlobalMode(this.config, options?.endpoint);
90
+ }
91
+
82
92
  this.validateConfig(this.config);
83
93
 
84
94
  return this.config;
@@ -100,9 +110,22 @@ export class ApibotConfigParser {
100
110
 
101
111
  getOutputDir(): string {
102
112
  const config = this.getConfig();
113
+ return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
114
+ }
115
+
116
+ getProjectRoot(): string {
117
+ if (this.site) return this.site.dir;
103
118
  const configPath = this.getConfigPath();
104
119
  if (!configPath) throw new Error('Config path not found');
105
- return path.join(path.dirname(configPath), config.dirs?.output || 'output');
120
+ return path.dirname(configPath);
121
+ }
122
+
123
+ resolveEndpointPath(endpoint: string): string {
124
+ if (!this.site) return endpoint;
125
+
126
+ const resolved = resolveSiteTarget(endpoint, this.site.url);
127
+ if (resolved.baseUrl !== this.site.url) return endpoint;
128
+ return resolved.path;
106
129
  }
107
130
 
108
131
  getPlansDir(): string {
@@ -115,9 +138,7 @@ export class ApibotConfigParser {
115
138
 
116
139
  getKnowledgeDir(): string {
117
140
  const config = this.getConfig();
118
- const configPath = this.getConfigPath();
119
- if (!configPath) throw new Error('Config path not found');
120
- return path.join(path.dirname(configPath), config.dirs?.knowledge || 'knowledge');
141
+ return path.join(this.getProjectRoot(), config.dirs?.knowledge || 'knowledge');
121
142
  }
122
143
 
123
144
  ensureDirectory(dirPath: string): void {
@@ -126,11 +147,20 @@ export class ApibotConfigParser {
126
147
  }
127
148
  }
128
149
 
150
+ private enterGlobalMode(config: ApibotConfig, endpoint?: string): void {
151
+ const site = resolveSiteTarget(endpoint);
152
+ this.site = registerSite(site.baseUrl);
153
+
154
+ config.dirs = { output: 'output', knowledge: 'knowledge' };
155
+ config.api = { ...config.api, baseEndpoint: site.baseUrl };
156
+ if (process.env.EXPLORBOT_API_SPEC) config.api.spec = [process.env.EXPLORBOT_API_SPEC];
157
+ }
158
+
129
159
  private async loadEnvConfig(): Promise<ApibotConfig> {
130
160
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
131
161
  const modelSpec = process.env.EXPLORBOT_AI_MODEL;
132
162
  if (!provider && !modelSpec) {
133
- throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
163
+ throw new ConfigMissingError(missingConfigMessage('apibot.config.js'));
134
164
  }
135
165
  if (modelSpec && !provider && !modelSpec.includes('/')) {
136
166
  throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
@@ -177,7 +207,8 @@ export class ApibotConfigParser {
177
207
  if (existsSync(fullPath)) return fullPath;
178
208
  }
179
209
 
180
- return null;
210
+ if (envConfigRequested()) return null;
211
+ return findGlobalConfig();
181
212
  }
182
213
 
183
214
  private async loadConfigModule(configPath: string): Promise<any> {
@@ -94,6 +94,9 @@ class Documentarian {
94
94
  if ((interaction.changes?.newElements || 0) > 0) {
95
95
  return true;
96
96
  }
97
+ if ((interaction.changes?.removedElements || 0) > 0) {
98
+ return true;
99
+ }
97
100
  return (interaction.discoveredUrls || []).length > 0;
98
101
  });
99
102
  }
@@ -49,7 +49,10 @@ export interface InteractionScreenshot {
49
49
  relativePath: string;
50
50
  }
51
51
 
52
- export type CaptureInteractionState = (state: WebPageState, transition: DocStateTransition) => Promise<InteractionScreenshot | null>;
52
+ export interface CaptureInteractionState {
53
+ before(): Promise<Buffer | null>;
54
+ after(beforeScreenshot: Buffer | null, state: WebPageState, transition: DocStateTransition): Promise<InteractionScreenshot | null>;
55
+ }
53
56
 
54
57
  const DEFAULT_MAX_PRIMARY_CANDIDATES = 3;
55
58
  const DEFAULT_MAX_INTERACTIONS = 5;
@@ -130,6 +133,8 @@ async function executeInteraction(explorer: Explorer, stateManager: StateManager
130
133
  return null;
131
134
  }
132
135
 
136
+ const beforeScreenshot = await captureState?.before();
137
+
133
138
  const executed = await attemptInteraction(explorer, candidate);
134
139
  if (!executed) {
135
140
  return null;
@@ -151,13 +156,13 @@ async function executeInteraction(explorer: Explorer, stateManager: StateManager
151
156
  });
152
157
 
153
158
  if (captureState && isMeaningfulStateTransition(transition)) {
154
- const screenshot = await captureState(afterState, transition);
159
+ const screenshot = await captureState.after(beforeScreenshot ?? null, afterState, transition);
155
160
  if (screenshot) {
156
161
  transition.screenshot = screenshot;
157
162
  }
158
163
  }
159
164
 
160
- if (urlChanged || ariaChanges.newCount > 0) {
165
+ if (urlChanged || ariaChanges.newCount > 0 || ariaChanges.removedCount > 0) {
161
166
  await restoreInteractionState(explorer, restoreUrl);
162
167
  }
163
168
 
@@ -192,11 +197,18 @@ async function restoreInteractionState(explorer: Explorer, restoreUrl: string, p
192
197
  }
193
198
 
194
199
  function buildTransition(candidate: InteractionCandidate, beforeState: WebPageState, afterState: WebPageState, changes: InteractionChanges): DocStateTransition {
200
+ const existingUrls = new Set(collectLinks(beforeState).map((link) => link.url));
195
201
  const transition: DocStateTransition = {
196
202
  action: describeAction(candidate),
197
203
  before: summarizeInteractiveState(beforeState),
198
204
  after: summarizeInteractiveState(afterState),
199
- discoveredUrls: collectLinks(afterState).map((link) => link.url),
205
+ discoveredUrls: [
206
+ ...new Set(
207
+ collectLinks(afterState)
208
+ .map((link) => link.url)
209
+ .filter((url) => !existingUrls.has(url))
210
+ ),
211
+ ],
200
212
  newCapabilities: collectDiscoveryNotes(afterState, changes),
201
213
  element: buildInteractionElement(candidate),
202
214
  changes,
@@ -239,7 +251,7 @@ function isMeaningfulStateTransition(transition: DocStateTransition): boolean {
239
251
  if (transition.targetUrl || transition.changes?.urlChanged) {
240
252
  return true;
241
253
  }
242
- return (transition.changes?.newElements || 0) > 0;
254
+ return (transition.changes?.newElements || 0) > 0 || (transition.changes?.removedElements || 0) > 0;
243
255
  }
244
256
 
245
257
  function buildInteractionElement(candidate: InteractionCandidate): InteractionElement {
@@ -55,6 +55,7 @@ export function createDocsCommands(name = 'docs'): Command {
55
55
  console.log(`Skipped ${result.skipped.length} page(s)`);
56
56
  console.log(`Spec index: ${result.indexPath}`);
57
57
  console.log(`Pages dir: ${path.join(result.outputDir, 'pages')}`);
58
+ console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
58
59
 
59
60
  await bot.stop();
60
61
  process.exit(0);
@@ -91,6 +92,7 @@ export function createDocsCommands(name = 'docs'): Command {
91
92
  output: 'docs',
92
93
  screenshot: true,
93
94
  interactive: false,
95
+ ignoreErrors: true,
94
96
  collapseDynamicPages: true,
95
97
  scope: 'site',
96
98
  includePaths: [],
@@ -115,6 +115,7 @@ class DocbotConfigParser {
115
115
  output: 'docs',
116
116
  screenshot: true,
117
117
  interactive: false,
118
+ ignoreErrors: true,
118
119
  collapseDynamicPages: true,
119
120
  scope: 'site',
120
121
  includePaths: [],
@@ -151,6 +152,7 @@ interface DocbotConfig {
151
152
  maxPages?: number;
152
153
  output?: string;
153
154
  screenshot?: boolean;
155
+ ignoreErrors?: boolean | string[];
154
156
  prompt?: string;
155
157
  collapseDynamicPages?: boolean;
156
158
  scope?: 'site' | 'section' | 'subtree';
@@ -6,11 +6,12 @@ import { normalizeUrl } from '../../../src/state-manager.ts';
6
6
  import { tag } from '../../../src/utils/logger.ts';
7
7
  import { sanitizeFilename } from '../../../src/utils/strings.ts';
8
8
  import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
9
+ import type { DocStateTransition } from './ai/tools.ts';
9
10
  import { type DocbotConfig, DocbotConfigParser } from './config.ts';
10
11
  import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
11
12
  import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
12
13
  import { extractResearchNavigationTargets } from './research-navigation.ts';
13
- import { type DocumentationScreenshot, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
14
+ import { type DocumentationScreenshot, captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
14
15
  import { renderMermaidBody } from './state-diagram.ts';
15
16
 
16
17
  class DocBot {
@@ -113,16 +114,18 @@ class DocBot {
113
114
  force: true,
114
115
  });
115
116
  const pagePath = this.getPageFilePath(state.url);
116
- const documentation = await this.documentarian.document(state, research, async (interactionState, transition) => {
117
- if (!this.shouldUseScreenshots()) {
118
- return null;
119
- }
120
- return captureInteractionScreenshot(this.explorBot.getExplorer(), interactionState, transition, {
121
- pageFilePath: pagePath,
122
- screenshotsDir: this.getScreenshotsDir(),
123
- config: this.config,
124
- });
125
- });
117
+ const captureState = this.shouldUseScreenshots()
118
+ ? {
119
+ before: () => captureBeforeInteraction(this.explorBot.getExplorer()),
120
+ after: (beforeScreenshot: Buffer | null, interactionState: WebPageState, transition: DocStateTransition) =>
121
+ captureInteractionScreenshot(this.explorBot.getExplorer(), beforeScreenshot, interactionState, transition, {
122
+ pageFilePath: pagePath,
123
+ screenshotsDir: this.getScreenshotsDir(),
124
+ config: this.config,
125
+ }),
126
+ }
127
+ : undefined;
128
+ const documentation = await this.documentarian.document(state, research, captureState);
126
129
  const lowSignalReason = this.getLowSignalReason(documentation, research);
127
130
  if (lowSignalReason) {
128
131
  skipped.push({
@@ -163,6 +166,9 @@ class DocBot {
163
166
  }
164
167
  } catch (error) {
165
168
  const reason = error instanceof Error ? error.message : String(error);
169
+ if (!this.shouldIgnoreError(error)) {
170
+ throw error;
171
+ }
166
172
  tag('warning').log(`Skipping ${target}: ${reason}`);
167
173
  skipped.push({
168
174
  url: target,
@@ -410,6 +416,28 @@ class DocBot {
410
416
  return `low-signal page: only ${documentation.can.length} proven actions and ${interactiveCount} interactive elements`;
411
417
  }
412
418
 
419
+ private shouldIgnoreError(error: unknown): boolean {
420
+ const ignoreErrors = this.config.docs?.ignoreErrors;
421
+ if (ignoreErrors === undefined || ignoreErrors === true) return true;
422
+ if (ignoreErrors === false) return false;
423
+
424
+ const details = [error instanceof Error ? error.name : '', error instanceof Error ? error.message : String(error)];
425
+ if (typeof error === 'object' && error && 'code' in error) {
426
+ details.push(String(error.code));
427
+ }
428
+ const normalized = details
429
+ .join(' ')
430
+ .toLowerCase()
431
+ .replaceAll(/[\W_]+/g, ' ');
432
+ return ignoreErrors.some((pattern) => {
433
+ const normalizedPattern = pattern
434
+ .trim()
435
+ .toLowerCase()
436
+ .replaceAll(/[\W_]+/g, ' ');
437
+ return normalizedPattern.length > 0 && normalized.includes(normalizedPattern);
438
+ });
439
+ }
440
+
413
441
  private countInteractiveElements(research: string): number {
414
442
  const matches = [...research.matchAll(/\((\d+) elements?\)/g)];
415
443
  return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 0);
@@ -1,9 +1,11 @@
1
1
  import path from 'node:path';
2
+ import matter from 'gray-matter';
3
+ import { APPLICATION_SPEC_FORMAT, APPLICATION_SPEC_VERSION } from '../../../src/application-spec-contract.ts';
2
4
  import { type WebPageState } from '../../../src/state-manager.ts';
5
+ import { normalizeInlineText } from '../../../src/utils/strings.ts';
3
6
  import type { PageDocumentation, StateTransition } from './ai/documentarian.ts';
4
7
  import type { DocumentationScreenshot } from './screenshots.ts';
5
- import { buildStateGraph, renderMermaidFromGraph, renderStateMapFromGraph, type DocumentedPage, type SkippedPage } from './state-diagram.ts';
6
- import { normalizeInlineText } from '../../../src/utils/strings.ts';
8
+ import { type DocumentedPage, type SkippedPage, buildStateGraph, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph } from './state-diagram.ts';
7
9
 
8
10
  function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = []): string {
9
11
  const lines: string[] = [];
@@ -33,6 +35,13 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
33
35
  }
34
36
 
35
37
  const interactions = documentation.interactions;
38
+ const pageStateDiagram = renderPageStateDiagram(state.title || state.url || 'page', state.url || '', interactions || []);
39
+ if (pageStateDiagram) {
40
+ lines.push('## State Map');
41
+ lines.push('');
42
+ lines.push(`\`\`\`mermaid\n${pageStateDiagram}\n\`\`\``);
43
+ lines.push('');
44
+ }
36
45
  if (interactions && interactions.length > 0) {
37
46
  lines.push('## State Transitions');
38
47
  lines.push('');
@@ -101,7 +110,11 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
101
110
  lines.push('');
102
111
  }
103
112
 
104
- return `${lines.join('\n').trimEnd()}\n`;
113
+ return matter.stringify(`${lines.join('\n').trimEnd()}\n`, {
114
+ url: state.url,
115
+ format: APPLICATION_SPEC_FORMAT,
116
+ version: APPLICATION_SPEC_VERSION,
117
+ });
105
118
  }
106
119
 
107
120
  function renderSpecIndex(outputDir: string, startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): string {
@@ -116,9 +129,10 @@ function renderSpecIndex(outputDir: string, startPath: string, pages: Documented
116
129
  lines.push(`Max pages: ${maxPages}`);
117
130
  lines.push('');
118
131
  const graph = buildStateGraph(outputDir, pages);
132
+ const mermaid = renderMermaidFromGraph(graph, true);
119
133
  lines.push('## State Transitions');
120
134
  lines.push('');
121
- lines.push(`\`\`\`mermaid\n${renderMermaidFromGraph(graph)}\n\`\`\``);
135
+ lines.push(`\`\`\`mermaid\n${mermaid}\n\`\`\``);
122
136
  lines.push('');
123
137
  const stateMap = renderStateMapFromGraph(graph);
124
138
  if (stateMap) {