explorbot 0.2.0 → 0.2.2

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 (145) hide show
  1. package/README.md +21 -5
  2. package/bin/explorbot-cli.ts +29 -7
  3. package/boat/api-tester/src/config.ts +45 -3
  4. package/boat/doc-collector/src/ai/documentarian.ts +8 -2
  5. package/boat/doc-collector/src/ai/tools.ts +29 -16
  6. package/boat/doc-collector/src/cli.ts +2 -6
  7. package/boat/doc-collector/src/config.ts +2 -0
  8. package/boat/doc-collector/src/docbot.ts +42 -14
  9. package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
  10. package/boat/doc-collector/src/screenshots.ts +24 -16
  11. package/dist/bin/explorbot-cli.js +26 -6
  12. package/dist/boat/api-tester/src/config.js +43 -4
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +7 -2
  14. package/dist/boat/doc-collector/src/ai/tools.js +21 -15
  15. package/dist/boat/doc-collector/src/cli.js +2 -5
  16. package/dist/boat/doc-collector/src/config.js +1 -0
  17. package/dist/boat/doc-collector/src/docbot.js +39 -13
  18. package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
  19. package/dist/boat/doc-collector/src/screenshots.js +25 -16
  20. package/dist/models.json +30 -0
  21. package/dist/package.json +8 -2
  22. package/dist/src/action.d.ts +11 -3
  23. package/dist/src/action.js +22 -5
  24. package/dist/src/ai/agent.d.ts +17 -0
  25. package/dist/src/ai/captain/idle-mode.js +1 -1
  26. package/dist/src/ai/captain/test-mode.js +1 -1
  27. package/dist/src/ai/captain/web-mode.js +18 -22
  28. package/dist/src/ai/captain.js +13 -26
  29. package/dist/src/ai/driller.d.ts +2 -13
  30. package/dist/src/ai/driller.js +23 -38
  31. package/dist/src/ai/historian/codeceptjs.js +1 -1
  32. package/dist/src/ai/historian/playwright.d.ts +3 -2
  33. package/dist/src/ai/historian/playwright.js +1 -1
  34. package/dist/src/ai/historian/screencast.d.ts +2 -1
  35. package/dist/src/ai/historian/screencast.js +2 -2
  36. package/dist/src/ai/historian.d.ts +5 -4
  37. package/dist/src/ai/navigator.d.ts +6 -2
  38. package/dist/src/ai/navigator.js +26 -22
  39. package/dist/src/ai/pilot.d.ts +13 -6
  40. package/dist/src/ai/pilot.js +49 -44
  41. package/dist/src/ai/planner.d.ts +3 -2
  42. package/dist/src/ai/planner.js +19 -17
  43. package/dist/src/ai/provider.js +0 -1
  44. package/dist/src/ai/quartermaster.d.ts +3 -2
  45. package/dist/src/ai/quartermaster.js +4 -4
  46. package/dist/src/ai/rerunner.d.ts +2 -11
  47. package/dist/src/ai/rerunner.js +9 -22
  48. package/dist/src/ai/researcher/coordinates.js +4 -3
  49. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  50. package/dist/src/ai/researcher/deep-analysis.js +6 -6
  51. package/dist/src/ai/researcher/locators.d.ts +9 -2
  52. package/dist/src/ai/researcher/locators.js +65 -8
  53. package/dist/src/ai/researcher/sections.d.ts +2 -0
  54. package/dist/src/ai/researcher/sections.js +2 -2
  55. package/dist/src/ai/researcher.js +29 -54
  56. package/dist/src/ai/task-agent.d.ts +18 -5
  57. package/dist/src/ai/task-agent.js +35 -6
  58. package/dist/src/ai/tester.d.ts +6 -12
  59. package/dist/src/ai/tester.js +37 -51
  60. package/dist/src/ai/tools.d.ts +11 -10
  61. package/dist/src/ai/tools.js +24 -36
  62. package/dist/src/command-handler.js +1 -1
  63. package/dist/src/commands/context-aria-command.js +1 -1
  64. package/dist/src/commands/context-command.js +7 -5
  65. package/dist/src/commands/context-data-command.js +2 -2
  66. package/dist/src/commands/context-experience-command.js +2 -2
  67. package/dist/src/commands/context-html-command.js +2 -2
  68. package/dist/src/commands/context-knowledge-command.js +1 -1
  69. package/dist/src/commands/drill-command.js +1 -1
  70. package/dist/src/commands/explore-command.js +4 -3
  71. package/dist/src/commands/freesail-command.js +2 -2
  72. package/dist/src/commands/learn-command.js +4 -4
  73. package/dist/src/commands/path-command.js +1 -1
  74. package/dist/src/commands/research-command.js +1 -1
  75. package/dist/src/commands/test-command.js +1 -1
  76. package/dist/src/components/App.js +1 -1
  77. package/dist/src/config.d.ts +17 -0
  78. package/dist/src/config.js +144 -10
  79. package/dist/src/experience-tracker.js +1 -1
  80. package/dist/src/explorbot.d.ts +17 -8
  81. package/dist/src/explorbot.js +74 -36
  82. package/dist/src/explorer.d.ts +67 -91
  83. package/dist/src/explorer.js +327 -567
  84. package/dist/src/state-manager.d.ts +6 -1
  85. package/dist/src/state-manager.js +1 -0
  86. package/dist/src/utils/hooks-runner.js +2 -4
  87. package/dist/src/utils/test-files.js +1 -1
  88. package/dist/src/utils/web-annotate.d.ts +5 -0
  89. package/dist/src/utils/web-annotate.js +58 -0
  90. package/dist/src/utils/web-eidx.d.ts +2 -0
  91. package/dist/src/utils/web-eidx.js +20 -0
  92. package/dist/src/utils/web-element.d.ts +1 -0
  93. package/dist/src/utils/web-element.js +29 -0
  94. package/models.json +30 -0
  95. package/package.json +8 -2
  96. package/src/action.ts +25 -5
  97. package/src/ai/agent.ts +20 -0
  98. package/src/ai/captain/idle-mode.ts +1 -1
  99. package/src/ai/captain/test-mode.ts +1 -1
  100. package/src/ai/captain/web-mode.ts +18 -23
  101. package/src/ai/captain.ts +12 -25
  102. package/src/ai/driller.ts +50 -67
  103. package/src/ai/historian/codeceptjs.ts +1 -1
  104. package/src/ai/historian/playwright.ts +4 -3
  105. package/src/ai/historian/screencast.ts +4 -3
  106. package/src/ai/historian.ts +3 -2
  107. package/src/ai/navigator.ts +30 -25
  108. package/src/ai/pilot.ts +55 -47
  109. package/src/ai/planner.ts +22 -20
  110. package/src/ai/provider.ts +0 -1
  111. package/src/ai/quartermaster.ts +5 -4
  112. package/src/ai/rerunner.ts +10 -29
  113. package/src/ai/researcher/coordinates.ts +4 -3
  114. package/src/ai/researcher/deep-analysis.ts +8 -6
  115. package/src/ai/researcher/locators.ts +66 -9
  116. package/src/ai/researcher/sections.ts +4 -2
  117. package/src/ai/researcher.ts +32 -61
  118. package/src/ai/task-agent.ts +45 -11
  119. package/src/ai/tester.ts +40 -57
  120. package/src/ai/tools.ts +32 -53
  121. package/src/command-handler.ts +1 -1
  122. package/src/commands/context-aria-command.ts +1 -1
  123. package/src/commands/context-command.ts +7 -5
  124. package/src/commands/context-data-command.ts +2 -2
  125. package/src/commands/context-experience-command.ts +2 -2
  126. package/src/commands/context-html-command.ts +2 -2
  127. package/src/commands/context-knowledge-command.ts +1 -1
  128. package/src/commands/drill-command.ts +1 -1
  129. package/src/commands/explore-command.ts +4 -3
  130. package/src/commands/freesail-command.ts +2 -2
  131. package/src/commands/learn-command.ts +4 -4
  132. package/src/commands/path-command.ts +1 -1
  133. package/src/commands/research-command.ts +1 -1
  134. package/src/commands/test-command.ts +1 -1
  135. package/src/components/App.tsx +1 -1
  136. package/src/config.ts +173 -11
  137. package/src/experience-tracker.ts +1 -1
  138. package/src/explorbot.ts +78 -39
  139. package/src/explorer.ts +388 -626
  140. package/src/state-manager.ts +7 -1
  141. package/src/utils/hooks-runner.ts +2 -4
  142. package/src/utils/test-files.ts +1 -1
  143. package/src/utils/web-annotate.ts +64 -0
  144. package/src/utils/web-eidx.ts +21 -0
  145. package/src/utils/web-element.ts +30 -0
package/README.md CHANGED
@@ -14,6 +14,8 @@ npx explorbot start https://your-app.com
14
14
 
15
15
  It runs with no babysitting and reports back what it finds. This is vibe-testing.
16
16
 
17
+ Explorbot works with any AI provider through the [Vercel AI SDK](https://sdk.vercel.ai/providers). See [`models.json`](models.json) for the current recommended provider and model setup, and [Providers](docs/basics/providers.md) for how to configure each one.
18
+
17
19
  New here? Read the [Getting Started guide](docs/basics/getting-started.md).
18
20
 
19
21
  ## Use Cases
@@ -92,7 +94,7 @@ Explorbot won't replace your regression tests — it covers what they can't. You
92
94
  ## Requirements
93
95
 
94
96
  - Node.js 24+ or **Bun**
95
- - An **AI provider key** — OpenRouter recommended; Groq, Cerebras, OpenAI, Anthropic, and others via the [Vercel AI SDK](https://sdk.vercel.ai/providers)
97
+ - An **AI provider key** — OpenRouter recommended; Groq, Cerebras, [OpenAI](docs/basics/providers.md#openai), Anthropic, and others via the [Vercel AI SDK](https://sdk.vercel.ai/providers)
96
98
  - A **modern terminal** — iTerm2, WARP, Kitty, Ghostty, or Windows Terminal with WSL
97
99
  - A **compatible web app** — CRUD-heavy apps fit best. See [Prerequisites](docs/basics/prerequisites.md)
98
100
 
@@ -125,6 +127,19 @@ Type `/explore`, and Explorbot runs its loop on its own — research, plan, test
125
127
 
126
128
  That's the gist. The [**Getting Started guide**](docs/basics/getting-started.md) walks through the full setup — choosing models, teaching Explorbot to log in, and picking the right feature to start on.
127
129
 
130
+ ### Or skip the config file
131
+
132
+ For a CI job, a demo, or a coding agent, pass everything as environment variables. Name a provider and Explorbot picks its recommended models:
133
+
134
+ ```bash
135
+ EXPLORBOT_URL=https://app.example.com \
136
+ EXPLORBOT_AI_PROVIDER=openrouter \
137
+ EXPLORBOT_KNOWLEDGE="Log in as admin@example.com / secret123" \
138
+ npx explorbot explore /admin/users --max-tests 3
139
+ ```
140
+
141
+ Output lands in a temp directory and nothing is written to your project. See [Agentic Usage](docs/workflow/agentic-usage.md).
142
+
128
143
  ## Teaching Explorbot
129
144
 
130
145
  Explorbot gets better when you tell it about your app:
@@ -146,12 +161,13 @@ When you're ready to go deeper, the [full documentation](docs/) covers everythin
146
161
  ## FAQ
147
162
 
148
163
  **Can I run it in Cursor or Claude Code?**
149
- No, Explorbot is a separate application designed for constant testing. Cursor, Codex, and Claude Code are coding agents not relevant here.
150
-
151
- > However, Explorbot can be used as a subagent or terminal command controlled by a coding agent.
164
+ Not as a replacement — Explorbot is a separate application designed for constant testing, while Cursor, Codex, and Claude Code are coding agents. But a coding agent can drive Explorbot as a terminal command or subagent: it writes the test plan, Explorbot executes it against the real app. See [Agentic Usage](docs/workflow/agentic-usage.md).
152
165
 
153
166
  **Can I bring a Cursor or OpenAI subscription?**
154
- No. Their models are too slow for the way Explorbot works. Use pay-per-token providers like Groq and OpenRouter.
167
+ No. Explorbot needs an API key, not a chat subscription. Use pay-per-token access Groq, OpenRouter, or OpenAI's own API.
168
+
169
+ **Can I use OpenAI directly?**
170
+ Yes. Add your `OPENAI_API_KEY` and point the models at OpenAI — a nano-class model for `model` and `visionModel`, a stronger one for `agenticModel`. Expect it to run a bit slower than hosted OSS models on Groq or Cerebras. See [Providers](docs/basics/providers.md#openai) for the config.
155
171
 
156
172
  **I want to use Opus!!!**
157
173
  Opus is great for coding. Testing needs a simpler model that can safely consume lots of HTML tokens, fast. Save the expensive models for sophisticated decision-making.
@@ -9,7 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
- import { ConfigParser } from '../src/config.js';
12
+ import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
13
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
14
14
  import { Stats } from '../src/stats.js';
15
15
  import { Plan } from '../src/test-plan.js';
@@ -43,8 +43,6 @@ interface CLIOptions {
43
43
  }
44
44
 
45
45
  function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
46
- const sessionFile = options.session === true ? path.join(path.resolve(options.path || process.cwd()), 'output', 'session.json') : options.session;
47
-
48
46
  return {
49
47
  from,
50
48
  verbose: options.verbose || options.debug,
@@ -53,7 +51,7 @@ function buildExplorBotOptions(from: string | undefined, options: CLIOptions): E
53
51
  show: options.show,
54
52
  headless: options.headless,
55
53
  incognito: options.incognito,
56
- session: sessionFile,
54
+ session: options.session,
57
55
  } as ExplorBotOptions;
58
56
  }
59
57
 
@@ -690,7 +688,7 @@ program
690
688
  path: options.path,
691
689
  config: options.config,
692
690
  headless: true,
693
- session: options.session === true ? 'output/session.json' : options.session,
691
+ session: options.session,
694
692
  };
695
693
 
696
694
  const explorBot = new ExplorBot(mainOptions);
@@ -720,11 +718,11 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
720
718
  await explorBot.start();
721
719
  await explorBot.agentNavigator().visit(url);
722
720
 
723
- const action = explorBot.getExplorer().createAction();
721
+ const action = explorBot.getExplorer().action();
724
722
  await action.execute(command);
725
723
 
726
724
  log('Command executed successfully');
727
- const state = explorBot.getExplorer().getStateManager().getCurrentState();
725
+ const state = explorBot.stateManager().getCurrentState();
728
726
  if (state) log(`URL: ${state.url}`);
729
727
 
730
728
  await explorBot.stop();
@@ -876,4 +874,28 @@ import { createDocsCommands } from '../boat/doc-collector/src/cli.ts';
876
874
  program.addCommand(createApiCommands('api'));
877
875
  program.addCommand(createDocsCommands('docs'));
878
876
 
877
+ const envHelp = () => {
878
+ const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
879
+ const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
880
+
881
+ return `
882
+ Environment variables (config-free one-liner mode):
883
+ Set EXPLORBOT_AI_PROVIDER to run without an explorbot.config.js. A config file always wins.
884
+
885
+ ${rows}
886
+
887
+ Providers: ${Object.keys(PROVIDERS).join(', ')}
888
+ Example:
889
+ EXPLORBOT_URL=https://app.example.com EXPLORBOT_AI_PROVIDER=openrouter \\
890
+ ${cli} explore /login --max-tests 3
891
+ `;
892
+ };
893
+
894
+ const addEnvHelp = (cmd: Command) => {
895
+ cmd.addHelpText('after', envHelp);
896
+ for (const sub of cmd.commands) addEnvHelp(sub);
897
+ };
898
+
899
+ addEnvHelp(program);
900
+
879
901
  program.parse();
@@ -1,7 +1,7 @@
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 } from '../../../src/config.ts';
4
+ import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from '../../../src/config.ts';
5
5
 
6
6
  export type { AIConfig };
7
7
 
@@ -54,8 +54,11 @@ export class ApibotConfigParser {
54
54
 
55
55
  const resolvedPath = options?.config || this.findConfigFile();
56
56
  if (!resolvedPath) {
57
- if (options?.path) process.chdir(originalCwd);
58
- throw new Error('No configuration file found. Create apibot.config.js or apibot.config.ts');
57
+ try {
58
+ return await this.loadEnvConfig();
59
+ } finally {
60
+ if (options?.path && originalCwd !== process.cwd()) process.chdir(originalCwd);
61
+ }
59
62
  }
60
63
 
61
64
  try {
@@ -123,6 +126,45 @@ export class ApibotConfigParser {
123
126
  }
124
127
  }
125
128
 
129
+ private async loadEnvConfig(): Promise<ApibotConfig> {
130
+ const provider = process.env.EXPLORBOT_AI_PROVIDER;
131
+ const modelSpec = process.env.EXPLORBOT_AI_MODEL;
132
+ 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');
134
+ }
135
+ if (modelSpec && !provider && !modelSpec.includes('/')) {
136
+ throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
137
+ }
138
+
139
+ const baseEndpoint = process.env.EXPLORBOT_URL;
140
+ if (!baseEndpoint) {
141
+ throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
142
+ }
143
+
144
+ const outputRoot = resolveOutputRoot();
145
+ materializeKnowledge(outputRoot);
146
+
147
+ const api: ApiConfig = { baseEndpoint };
148
+ if (process.env.EXPLORBOT_API_SPEC) {
149
+ api.spec = [process.env.EXPLORBOT_API_SPEC];
150
+ }
151
+
152
+ let model: any;
153
+ if (provider && modelSpec) model = await createModel(provider, modelSpec);
154
+ if (provider && !modelSpec) model = await resolveModel(provider, 'model');
155
+ if (!provider) model = await resolveModel(modelSpec!, 'model');
156
+
157
+ this.config = {
158
+ ai: { model },
159
+ api,
160
+ dirs: { output: '.', knowledge: 'knowledge' },
161
+ };
162
+ this.configPath = path.join(outputRoot, 'apibot.config.js');
163
+ this.validateConfig(this.config);
164
+
165
+ return this.config;
166
+ }
167
+
126
168
  private findConfigFile(): string | null {
127
169
  const apibotPaths = ['apibot.config.js', 'apibot.config.mjs', 'apibot.config.ts'];
128
170
  for (const p of apibotPaths) {
@@ -2,6 +2,7 @@ import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import type { AIProvider } from '../../../../src/ai/provider.ts';
4
4
  import type Explorer from '../../../../src/explorer.ts';
5
+ import type { StateManager } from '../../../../src/state-manager.ts';
5
6
  import type { WebPageState } from '../../../../src/state-manager.ts';
6
7
  import { tag } from '../../../../src/utils/logger.ts';
7
8
  import type { DocbotConfig } from '../config.ts';
@@ -11,11 +12,13 @@ class Documentarian {
11
12
  private provider: AIProvider;
12
13
  private config: DocbotConfig;
13
14
  private explorer?: Explorer;
15
+ private stateManager?: StateManager;
14
16
 
15
- constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer) {
17
+ constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer, stateManager?: StateManager) {
16
18
  this.provider = provider;
17
19
  this.config = config;
18
20
  this.explorer = explorer;
21
+ this.stateManager = stateManager;
19
22
  }
20
23
 
21
24
  async document(state: WebPageState, research: string, captureState?: CaptureInteractionState): Promise<PageDocumentation> {
@@ -46,7 +49,7 @@ class Documentarian {
46
49
  try {
47
50
  tag('info').log('Starting interactive exploration...');
48
51
 
49
- const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research, this.config, captureState);
52
+ const deterministicInteractions = await collectDocInteractions(this.explorer!, this.stateManager!, state, research, this.config, captureState);
50
53
  meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
51
54
  if (meaningfulInteractions.length > 0) {
52
55
  tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
@@ -91,6 +94,9 @@ class Documentarian {
91
94
  if ((interaction.changes?.newElements || 0) > 0) {
92
95
  return true;
93
96
  }
97
+ if ((interaction.changes?.removedElements || 0) > 0) {
98
+ return true;
99
+ }
94
100
  return (interaction.discoveredUrls || []).length > 0;
95
101
  });
96
102
  }
@@ -1,6 +1,6 @@
1
1
  import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
2
2
  import type Explorer from '../../../../src/explorer.ts';
3
- import type { WebPageState } from '../../../../src/state-manager.ts';
3
+ import type { StateManager, WebPageState } from '../../../../src/state-manager.ts';
4
4
  import { detectFocusArea } from '../../../../src/utils/aria.ts';
5
5
  import type { DocbotConfig } from '../config.ts';
6
6
 
@@ -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;
@@ -58,14 +61,14 @@ const DEFAULT_WAIT_MS = 700;
58
61
  const TAB_WAIT_MS = 500;
59
62
  const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
60
63
 
61
- export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
64
+ export async function collectDocInteractions(explorer: Explorer, stateManager: StateManager, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
62
65
  const sections = parseResearchSections(research);
63
66
  const transitions: DocStateTransition[] = [];
64
67
  const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
65
68
  const tabGroup = findTabGroup(sections);
66
69
 
67
70
  if (tabGroup) {
68
- transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
71
+ transitions.push(...(await exploreTabGroup(explorer, stateManager, tabGroup, state.url, maxInteractions, captureState)));
69
72
  }
70
73
 
71
74
  for (const candidate of findActionCandidates(sections, config)) {
@@ -73,7 +76,7 @@ export async function collectDocInteractions(explorer: Explorer, state: WebPageS
73
76
  break;
74
77
  }
75
78
 
76
- const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
79
+ const transition = await executeInteraction(explorer, stateManager, candidate, state.url, DEFAULT_WAIT_MS, captureState);
77
80
  if (!transition) {
78
81
  continue;
79
82
  }
@@ -92,7 +95,7 @@ export function pickDocActionCandidates(research: string, config: DocbotConfig =
92
95
  }));
93
96
  }
94
97
 
95
- async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
98
+ async function exploreTabGroup(explorer: Explorer, stateManager: StateManager, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
96
99
  const transitions: DocStateTransition[] = [];
97
100
 
98
101
  for (const element of tabGroup.elements) {
@@ -102,6 +105,7 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc
102
105
 
103
106
  const transition = await executeInteraction(
104
107
  explorer,
108
+ stateManager,
105
109
  {
106
110
  element,
107
111
  container: tabGroup.container,
@@ -123,12 +127,14 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc
123
127
  return transitions;
124
128
  }
125
129
 
126
- async function executeInteraction(explorer: Explorer, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
127
- const beforeState = explorer.getStateManager().getCurrentState();
130
+ async function executeInteraction(explorer: Explorer, stateManager: StateManager, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
131
+ const beforeState = stateManager.getCurrentState();
128
132
  if (!beforeState) {
129
133
  return null;
130
134
  }
131
135
 
136
+ const beforeScreenshot = await captureState?.before();
137
+
132
138
  const executed = await attemptInteraction(explorer, candidate);
133
139
  if (!executed) {
134
140
  return null;
@@ -136,7 +142,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand
136
142
 
137
143
  await wait(waitMs);
138
144
 
139
- const afterState = explorer.getStateManager().getCurrentState();
145
+ const afterState = stateManager.getCurrentState();
140
146
  if (!afterState) {
141
147
  return null;
142
148
  }
@@ -150,13 +156,13 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand
150
156
  });
151
157
 
152
158
  if (captureState && isMeaningfulStateTransition(transition)) {
153
- const screenshot = await captureState(afterState, transition);
159
+ const screenshot = await captureState.after(beforeScreenshot ?? null, afterState, transition);
154
160
  if (screenshot) {
155
161
  transition.screenshot = screenshot;
156
162
  }
157
163
  }
158
164
 
159
- if (urlChanged || ariaChanges.newCount > 0) {
165
+ if (urlChanged || ariaChanges.newCount > 0 || ariaChanges.removedCount > 0) {
160
166
  await restoreInteractionState(explorer, restoreUrl);
161
167
  }
162
168
 
@@ -164,7 +170,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand
164
170
  }
165
171
 
166
172
  async function attemptInteraction(explorer: Explorer, candidate: InteractionCandidate): Promise<boolean> {
167
- const action = explorer.createAction();
173
+ const action = explorer.action();
168
174
 
169
175
  for (const command of buildClickCommands(candidate.element, candidate.container)) {
170
176
  const success = await action.attempt(command, buildPurpose(candidate));
@@ -178,7 +184,7 @@ async function attemptInteraction(explorer: Explorer, candidate: InteractionCand
178
184
 
179
185
  async function restoreInteractionState(explorer: Explorer, restoreUrl: string, primaryCommand?: string | null): Promise<void> {
180
186
  if (primaryCommand) {
181
- const action = explorer.createAction();
187
+ const action = explorer.action();
182
188
  const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
183
189
  if (restored) {
184
190
  await wait(TAB_WAIT_MS);
@@ -186,16 +192,23 @@ async function restoreInteractionState(explorer: Explorer, restoreUrl: string, p
186
192
  }
187
193
  }
188
194
 
189
- const action = explorer.createAction();
195
+ const action = explorer.action();
190
196
  await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
191
197
  }
192
198
 
193
199
  function buildTransition(candidate: InteractionCandidate, beforeState: WebPageState, afterState: WebPageState, changes: InteractionChanges): DocStateTransition {
200
+ const existingUrls = new Set(collectLinks(beforeState).map((link) => link.url));
194
201
  const transition: DocStateTransition = {
195
202
  action: describeAction(candidate),
196
203
  before: summarizeInteractiveState(beforeState),
197
204
  after: summarizeInteractiveState(afterState),
198
- 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
+ ],
199
212
  newCapabilities: collectDiscoveryNotes(afterState, changes),
200
213
  element: buildInteractionElement(candidate),
201
214
  changes,
@@ -238,7 +251,7 @@ function isMeaningfulStateTransition(transition: DocStateTransition): boolean {
238
251
  if (transition.targetUrl || transition.changes?.urlChanged) {
239
252
  return true;
240
253
  }
241
- return (transition.changes?.newElements || 0) > 0;
254
+ return (transition.changes?.newElements || 0) > 0 || (transition.changes?.removedElements || 0) > 0;
242
255
  }
243
256
 
244
257
  function buildInteractionElement(candidate: InteractionCandidate): InteractionElement {
@@ -5,11 +5,6 @@ import { setPreserveConsoleLogs } from '../../../src/utils/logger.ts';
5
5
  import { DocBot, type DocbotOptions } from './docbot.ts';
6
6
 
7
7
  function buildOptions(options: any): DocbotOptions {
8
- let session = options.session;
9
- if (options.session === true) {
10
- session = 'output/session.json';
11
- }
12
-
13
8
  return {
14
9
  verbose: options.verbose || options.debug,
15
10
  config: options.config,
@@ -17,7 +12,7 @@ function buildOptions(options: any): DocbotOptions {
17
12
  show: options.show,
18
13
  headless: options.headless,
19
14
  incognito: options.incognito,
20
- session,
15
+ session: options.session,
21
16
  docsConfig: options.docsConfig,
22
17
  };
23
18
  }
@@ -96,6 +91,7 @@ export function createDocsCommands(name = 'docs'): Command {
96
91
  output: 'docs',
97
92
  screenshot: true,
98
93
  interactive: false,
94
+ ignoreErrors: true,
99
95
  collapseDynamicPages: true,
100
96
  scope: 'site',
101
97
  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,12 +6,13 @@ 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
- import { renderMermaidBody } from './state-diagram.ts';
12
12
  import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
13
13
  import { extractResearchNavigationTargets } from './research-navigation.ts';
14
- import { type DocumentationScreenshot, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
14
+ import { type DocumentationScreenshot, captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
15
+ import { renderMermaidBody } from './state-diagram.ts';
15
16
 
16
17
  class DocBot {
17
18
  private explorBot: ExplorBot;
@@ -43,7 +44,7 @@ class DocBot {
43
44
  config: this.options.docsConfig,
44
45
  path: this.options.path,
45
46
  });
46
- this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
47
+ this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer(), this.explorBot.stateManager());
47
48
  this.ensureDirectory(this.configParser.getOutputDir());
48
49
  this.ensureDirectory(this.getPagesDir());
49
50
  }
@@ -76,7 +77,7 @@ class DocBot {
76
77
  continue;
77
78
  }
78
79
 
79
- const stateManager = this.explorBot.getExplorer().getStateManager();
80
+ const stateManager = this.explorBot.stateManager();
80
81
  if (stateManager.hasVisitedState(target)) {
81
82
  continue;
82
83
  }
@@ -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);