explorbot 0.1.19 → 0.1.21

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.
@@ -305,38 +305,47 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
305
305
  }
306
306
  });
307
307
 
308
- addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern')).action(async (planfile, index, options) => {
309
- try {
310
- const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
311
- await explorBot.start();
308
+ addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern').option('--from-plan <file>', 'Load plan file when the first argument is a test index')).action(
309
+ async (planfile, index, options) => {
310
+ try {
311
+ const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
312
+ await explorBot.start();
312
313
 
313
- const plan = explorBot.loadPlan(planfile);
314
- const pending = plan.getPendingTests();
315
- log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
314
+ let planfileArg = planfile;
315
+ let indexArg = index;
316
+ if (options.fromPlan) {
317
+ planfileArg = options.fromPlan;
318
+ indexArg = planfile;
319
+ }
316
320
 
317
- const startUrl = plan.url || pending[0]?.startUrl;
318
- if (!startUrl) {
319
- throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
320
- }
321
+ const plan = explorBot.loadPlan(planfileArg);
322
+ const pending = plan.getPendingTests();
323
+ log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
321
324
 
322
- log(`Navigating to ${startUrl}`);
323
- await explorBot.visit(startUrl);
325
+ const startUrl = plan.url || pending[0]?.startUrl;
326
+ if (!startUrl) {
327
+ throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
328
+ }
324
329
 
325
- let args = '';
326
- if (index) args = index;
327
- else if (options.grep) args = options.grep;
330
+ log(`Navigating to ${startUrl}`);
331
+ await explorBot.visit(startUrl);
328
332
 
329
- const { TestCommand } = await import('../src/commands/test-command.js');
330
- const cmd = new TestCommand(explorBot);
331
- await cmd.execute(args);
333
+ let args = '';
334
+ if (indexArg) args = indexArg;
335
+ else if (options.grep) args = options.grep;
332
336
 
333
- await explorBot.stop();
334
- await showStatsAndExit(0);
335
- } catch (error) {
336
- console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
337
- await showStatsAndExit(1);
337
+ const { TestCommand } = await import('../src/commands/test-command.js');
338
+ const cmd = new TestCommand(explorBot);
339
+ await cmd.execute(args);
340
+
341
+ await explorBot.stop();
342
+ await showStatsAndExit(0);
343
+ } catch (error) {
344
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
345
+ await showStatsAndExit(1);
346
+ }
338
347
  }
339
- });
348
+ );
340
349
 
341
350
  program
342
351
  .command('runs [file]')
@@ -358,6 +367,26 @@ program
358
367
  }
359
368
  });
360
369
 
370
+ program
371
+ .command('plans [plan]')
372
+ .description('List saved plans, or show tests for a specific plan')
373
+ .option('-p, --path <path>', 'Working directory path')
374
+ .option('-c, --config <path>', 'Path to configuration file')
375
+ .action(async (plan, options) => {
376
+ try {
377
+ await ConfigParser.getInstance().loadConfig({
378
+ config: options.config,
379
+ path: options.path || process.cwd(),
380
+ });
381
+ const explorBot = new ExplorBot({ path: options.path });
382
+ const { PlansCommand } = await import('../src/commands/plans-command.js');
383
+ await new PlansCommand(explorBot).execute(plan || '');
384
+ } catch (error) {
385
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
386
+ process.exit(1);
387
+ }
388
+ });
389
+
361
390
  addCommonOptions(program.command('rerun <filename> [index]').description('Re-run generated tests with AI auto-healing')).action(async (filename, index, options) => {
362
391
  try {
363
392
  const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
@@ -599,6 +628,22 @@ addCommonOptions(program.command('research <url>').description('Research a page
599
628
  }
600
629
  );
601
630
 
631
+ addCommonOptions(program.command('navigate <url>').description('Navigate to a URL using the AI Navigator. Exits 0 if reachable, 1 otherwise.')).action(async (url, options) => {
632
+ try {
633
+ const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
634
+ await explorBot.start();
635
+
636
+ const { NavigateCommand } = await import('../src/commands/navigate-command.js');
637
+ await new NavigateCommand(explorBot).execute(url);
638
+
639
+ await explorBot.stop();
640
+ await showStatsAndExit(0);
641
+ } catch (error) {
642
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
643
+ await showStatsAndExit(1);
644
+ }
645
+ });
646
+
602
647
  addCommonOptions(
603
648
  program.command('drill <url>').alias('driller').description('Drill all components on a page to learn interactions').option('--knowledge <path>', 'Save learned interactions to knowledge file at this URL path').option('--max-components <count>', 'Maximum number of components to drill')
604
649
  ).action(async (url, options) => {
@@ -1,19 +1,35 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import type { AIProvider } from '../../../../src/ai/provider.ts';
4
+ import type Explorer from '../../../../src/explorer.ts';
4
5
  import type { WebPageState } from '../../../../src/state-manager.ts';
6
+ import { tag } from '../../../../src/utils/logger.ts';
5
7
  import type { DocbotConfig } from '../config.ts';
8
+ import { collectDocInteractions } from './tools.ts';
6
9
 
7
10
  class Documentarian {
8
11
  private provider: AIProvider;
9
12
  private config: DocbotConfig;
13
+ private explorer?: Explorer;
10
14
 
11
- constructor(provider: AIProvider, config: DocbotConfig = {}) {
15
+ constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer) {
12
16
  this.provider = provider;
13
17
  this.config = config;
18
+ this.explorer = explorer;
14
19
  }
15
20
 
16
21
  async document(state: WebPageState, research: string): Promise<PageDocumentation> {
22
+ const interactiveEnabled = this.config.docs?.interactive === true && this.explorer;
23
+ if (!interactiveEnabled) {
24
+ tag('info').log('Documentarian: Using static mode (interactive disabled or no explorer)');
25
+ return this.documentStatic(state, research);
26
+ }
27
+
28
+ tag('info').log('Documentarian: Using interactive mode with tools');
29
+ return this.documentWithInteraction(state, research);
30
+ }
31
+
32
+ private async documentStatic(state: WebPageState, research: string): Promise<PageDocumentation> {
17
33
  try {
18
34
  return await this.generateDocumentation(state, research);
19
35
  } catch (error) {
@@ -25,40 +41,124 @@ class Documentarian {
25
41
  }
26
42
  }
27
43
 
44
+ private async documentWithInteraction(state: WebPageState, research: string): Promise<PageDocumentation> {
45
+ try {
46
+ tag('info').log('Starting interactive exploration...');
47
+
48
+ const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research);
49
+ const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
50
+ if (meaningfulInteractions.length > 0) {
51
+ tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
52
+ return await this.generateDocumentationWithInteractions(state, research, meaningfulInteractions);
53
+ }
54
+
55
+ if (deterministicInteractions.length > 0) {
56
+ tag('info').log('Interactive exploration found only low-value navigation changes. Using static documentation.');
57
+ } else {
58
+ tag('info').log('Interactive exploration found no reliable deterministic interactions. Using static documentation.');
59
+ }
60
+
61
+ return this.documentStatic(state, research);
62
+ } catch (error) {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ tag('warning').log(`Interactive documentation failed: ${message}. Falling back to static.`);
65
+ return this.documentStatic(state, research);
66
+ }
67
+ }
68
+
69
+ private getMeaningfulInteractions(interactions: StateTransition[]): StateTransition[] {
70
+ return interactions.filter((interaction) => {
71
+ if (interaction.targetUrl) {
72
+ return true;
73
+ }
74
+ if (interaction.changes?.urlChanged) {
75
+ return true;
76
+ }
77
+ if ((interaction.changes?.newElements || 0) > 0) {
78
+ return true;
79
+ }
80
+ return (interaction.discoveredUrls || []).length > 0;
81
+ });
82
+ }
83
+
84
+ private async generateDocumentationWithInteractions(state: WebPageState, research: string, interactions: StateTransition[]): Promise<PageDocumentation> {
85
+ const messages = [
86
+ {
87
+ role: 'system' as const,
88
+ content: this.getSystemPrompt(),
89
+ },
90
+ {
91
+ role: 'user' as const,
92
+ content: this.buildPrompt(state, `${research}${this.buildInteractionContext(interactions)}`),
93
+ },
94
+ ];
95
+
96
+ const response = await this.provider.generateObject(messages, pageDocumentationSchema, undefined, {
97
+ agentName: 'documentarian',
98
+ });
99
+
100
+ return this.normalizeDocumentation(
101
+ {
102
+ ...(response.object as PageDocumentation),
103
+ interactions,
104
+ },
105
+ state,
106
+ research
107
+ );
108
+ }
109
+
110
+ private async generateDocumentation(state: WebPageState, research: string, simplified = false): Promise<PageDocumentation> {
111
+ const messages = [
112
+ {
113
+ role: 'system' as const,
114
+ content: this.getSystemPrompt(),
115
+ },
116
+ {
117
+ role: 'user' as const,
118
+ content: this.buildPrompt(state, research, simplified),
119
+ },
120
+ ];
121
+
122
+ const response = await this.provider.generateObject(messages, pageDocumentationSchema, undefined, {
123
+ agentName: 'documentarian',
124
+ });
125
+
126
+ return this.normalizeDocumentation(response.object as PageDocumentation, state, research);
127
+ }
128
+
28
129
  private getSystemPrompt(): string {
29
- const customPrompt = this.config.docs?.prompt;
30
130
  let promptSuffix = '';
31
- if (customPrompt) {
32
- promptSuffix = customPrompt;
131
+ if (this.config.docs?.prompt) {
132
+ promptSuffix = this.config.docs.prompt;
33
133
  }
34
134
 
35
135
  return dedent`
36
- <role>
37
- You are a product analyst preparing functional website documentation from UI research.
38
- </role>
39
-
40
- <task>
41
- Convert exploratory UI research into a precise spec of what users can do on the current page.
42
- Distinguish proven capabilities from assumptions.
43
- Prefer accuracy over coverage.
44
- </task>
45
-
46
- <rules>
47
- Only list capabilities that are grounded in the provided page research.
48
- Put actions into "can" only when there is direct evidence in the page context.
49
- Put actions into "might" only when the UI strongly suggests a capability but proof is incomplete.
50
- Describe each action from the end-user perspective.
51
- Be explicit about scope:
52
- - one item
53
- - list of items
54
- - bulk operations
55
- - all items
56
- - page-level
57
- Avoid implementation details, selectors, and QA wording.
58
- Avoid duplicate actions with different phrasing.
59
- </rules>
60
-
61
- ${promptSuffix}
136
+ <role>
137
+ You are a product analyst preparing functional website documentation from UI research.
138
+ </role>
139
+
140
+ <task>
141
+ Convert exploratory UI research into a precise spec of what users can do on the current page.
142
+ Distinguish proven capabilities from assumptions.
143
+ Prefer accuracy over coverage.
144
+ </task>
145
+
146
+ <rules>
147
+ Only list capabilities that are grounded in the provided page research.
148
+ Put actions into "can" only when there is direct evidence in the page context.
149
+ Put actions into "might" only when the UI strongly suggests a capability but proof is incomplete.
150
+ Describe each action from the end-user perspective.
151
+ Be explicit about scope:
152
+ - one item
153
+ - list of items
154
+ - bulk operations
155
+ - all items
156
+ - page-level
157
+ Avoid implementation details, selectors, and QA wording.
158
+ Avoid duplicate actions with different phrasing.
159
+ </rules>
160
+
161
+ ${promptSuffix}
62
162
  `;
63
163
  }
64
164
 
@@ -68,80 +168,104 @@ class Documentarian {
68
168
  .slice(0, 50)
69
169
  .map((link) => `- ${link.title}: ${link.url}`)
70
170
  .join('\n');
71
- const simplificationNote = simplified
72
- ? dedent`
171
+
172
+ let simplificationNote = '';
173
+ if (simplified) {
174
+ simplificationNote = dedent`
73
175
  <fallback_mode>
74
176
  The research text was simplified because the original formatting was noisy.
75
177
  Ignore malformed table syntax and rely only on clear, repeated signals.
76
178
  Prefer fewer actions over speculative coverage.
77
179
  </fallback_mode>
78
- `
79
- : '';
180
+ `;
181
+ }
80
182
 
81
183
  return dedent`
82
- <page>
83
- URL: ${state.url}
84
- Title: ${state.title || ''}
85
- Headings: ${headings}
86
- </page>
87
-
88
- <navigation_links>
89
- ${links}
90
- </navigation_links>
91
-
92
- <research>
93
- ${research}
94
- </research>
95
-
96
- ${simplificationNote}
97
-
98
- <output_requirements>
99
- Return structured data.
100
- summary: short page purpose statement.
101
- can: actions you are 100% sure are available on page.
102
- might: actions that look possible but are not fully proven.
103
- For each action provide:
104
- - action: concise user-facing capability phrased as "user can ..."
105
- - scope: one of one item, list of items, bulk operations, all items, page-level
106
- - evidence: short reason based on visible UI or research
107
- </output_requirements>
184
+ <page>
185
+ URL: ${state.url}
186
+ Title: ${state.title || ''}
187
+ Headings: ${headings}
188
+ </page>
189
+
190
+ <navigation_links>
191
+ ${links}
192
+ </navigation_links>
193
+
194
+ <research>
195
+ ${research}
196
+ </research>
197
+
198
+ ${simplificationNote}
199
+
200
+ <output_requirements>
201
+ Return structured data.
202
+ summary: short page purpose statement.
203
+ can: actions you are 100% sure are available on page.
204
+ might: actions that look possible but are not fully proven.
205
+ For each action provide:
206
+ - action: concise user-facing capability phrased as "user can ..."
207
+ - scope: one of one item, list of items, bulk operations, all items, page-level
208
+ - evidence: short reason based on visible UI or research
209
+ </output_requirements>
108
210
  `;
109
211
  }
110
212
 
111
- private async generateDocumentation(state: WebPageState, research: string, simplified = false): Promise<PageDocumentation> {
112
- const messages = [
113
- {
114
- role: 'system' as const,
115
- content: this.getSystemPrompt(),
116
- },
117
- {
118
- role: 'user' as const,
119
- content: this.buildPrompt(state, research, simplified),
120
- },
121
- ];
122
-
123
- const response = await this.provider.generateObject(messages, pageDocumentationSchema, undefined, {
124
- agentName: 'documentarian',
125
- });
213
+ private buildInteractionContext(interactions: StateTransition[]): string {
214
+ const lines = interactions
215
+ .map((interaction) => {
216
+ const parts = [`Action: ${interaction.action}`, `Element: ${this.formatInteractionElement(interaction)}`, `Before: ${interaction.before}`, `After: ${interaction.after}`, `Changes: ${this.formatInteractionChanges(interaction)}`];
217
+ if (interaction.targetUrl) {
218
+ parts.push(`Target URL: ${interaction.targetUrl}`);
219
+ }
220
+ if (interaction.discoveredUrls && interaction.discoveredUrls.length > 0) {
221
+ parts.push(`Discovered URLs: ${interaction.discoveredUrls.join(', ')}`);
222
+ }
223
+ return `- ${parts.join('\n ')}`;
224
+ })
225
+ .join('\n');
226
+ return dedent`
126
227
 
127
- return response.object as PageDocumentation;
228
+ <interaction_observations>
229
+ These are raw observations collected after interacting with visible controls. They are not semantic conclusions.
230
+ Classify them yourself as proven user capabilities, possible capabilities, navigation, page-state changes, or noise.
231
+ Do not trust the action label as a capability category; use the before/after state, element metadata, and URL changes as evidence.
232
+ ${lines}
233
+ </interaction_observations>
234
+ `;
128
235
  }
129
236
 
130
237
  private shouldRetryWithSanitizedResearch(error: unknown): boolean {
131
238
  const message = error instanceof Error ? error.message : String(error);
132
- return message.includes('Failed to generate JSON') || message.includes('failed_generation');
239
+ return message.includes('Failed to generate JSON') || message.includes('Failed to validate JSON') || message.includes('failed_generation') || message.includes('No object generated') || message.includes('response did not match schema');
240
+ }
241
+
242
+ private normalizeDocumentation(documentation: PageDocumentation, _state: WebPageState, _research: string): PageDocumentation {
243
+ const qualityNotes = this.evaluateDocumentationQuality(documentation);
244
+
245
+ return {
246
+ ...documentation,
247
+ qualityNotes,
248
+ };
249
+ }
250
+
251
+ private evaluateDocumentationQuality(documentation: PageDocumentation): string[] {
252
+ const notes: string[] = [];
253
+
254
+ if ((documentation.interactions || []).length === 0 && this.config.docs?.interactive) {
255
+ notes.push('Interactive exploration did not produce any reliable page-specific transitions for this page.');
256
+ }
257
+
258
+ return notes;
133
259
  }
134
260
 
135
261
  private sanitizeResearch(research: string): string {
136
- const lines = research.split('\n');
137
262
  const sanitized: string[] = [];
138
263
 
139
- for (const line of lines) {
264
+ for (const line of research.split('\n')) {
140
265
  if (!line.trim()) {
141
266
  sanitized.push(line);
142
267
  continue;
143
268
  }
144
-
145
269
  if (!line.includes('|')) {
146
270
  sanitized.push(line);
147
271
  continue;
@@ -151,12 +275,10 @@ class Documentarian {
151
275
  if (pipeCount < 2) {
152
276
  continue;
153
277
  }
154
-
155
278
  if (line.includes('|------')) {
156
279
  sanitized.push(line);
157
280
  continue;
158
281
  }
159
-
160
282
  if (line.trim().startsWith('|') && pipeCount >= 4) {
161
283
  sanitized.push(line);
162
284
  }
@@ -164,6 +286,29 @@ class Documentarian {
164
286
 
165
287
  return sanitized.join('\n');
166
288
  }
289
+
290
+ private formatInteractionElement(interaction: StateTransition): string {
291
+ if (!interaction.element) {
292
+ return 'unknown element';
293
+ }
294
+
295
+ const parts = [`role=${interaction.element.role}`, `name=${interaction.element.name}`, `section=${interaction.element.section}`];
296
+ if (interaction.element.container) {
297
+ parts.push(`container=${interaction.element.container}`);
298
+ }
299
+ if (interaction.element.locator) {
300
+ parts.push(`locator=${interaction.element.locator}`);
301
+ }
302
+ return parts.join(', ');
303
+ }
304
+
305
+ private formatInteractionChanges(interaction: StateTransition): string {
306
+ if (!interaction.changes) {
307
+ return 'unknown changes';
308
+ }
309
+
310
+ return `urlChanged=${interaction.changes.urlChanged}, newElements=${interaction.changes.newElements}, removedElements=${interaction.changes.removedElements}`;
311
+ }
167
312
  }
168
313
 
169
314
  const capabilitySchema = z.object({
@@ -172,13 +317,43 @@ const capabilitySchema = z.object({
172
317
  evidence: z.string(),
173
318
  });
174
319
 
320
+ const stateTransitionSchema = z.object({
321
+ action: z.string(),
322
+ before: z.string(),
323
+ after: z.string(),
324
+ targetUrl: z.string().optional(),
325
+ discoveredUrls: z.array(z.string()).optional(),
326
+ newCapabilities: z.array(z.string()).optional(),
327
+ element: z
328
+ .object({
329
+ role: z.string(),
330
+ name: z.string(),
331
+ section: z.string(),
332
+ container: z.string().optional(),
333
+ locator: z.string().optional(),
334
+ })
335
+ .optional(),
336
+ changes: z
337
+ .object({
338
+ urlChanged: z.boolean(),
339
+ newElements: z.number(),
340
+ removedElements: z.number(),
341
+ })
342
+ .optional(),
343
+ });
344
+
175
345
  const pageDocumentationSchema = z.object({
176
346
  summary: z.string(),
177
347
  can: z.array(capabilitySchema),
178
348
  might: z.array(capabilitySchema),
349
+ interactions: z.array(stateTransitionSchema).optional(),
179
350
  });
180
351
 
181
- type PageDocumentation = z.infer<typeof pageDocumentationSchema>;
352
+ type StateTransition = z.infer<typeof stateTransitionSchema>;
353
+ type PageDocumentation = z.infer<typeof pageDocumentationSchema> & {
354
+ interactions?: StateTransition[];
355
+ qualityNotes?: string[];
356
+ };
182
357
 
183
358
  export { Documentarian };
184
- export type { PageDocumentation };
359
+ export type { PageDocumentation, StateTransition };