explorbot 0.1.29 → 0.1.31

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 (51) hide show
  1. package/boat/doc-collector/src/ai/documentarian.ts +37 -13
  2. package/boat/doc-collector/src/ai/tools.ts +60 -20
  3. package/boat/doc-collector/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/config.ts +7 -0
  5. package/boat/doc-collector/src/docbot.ts +23 -5
  6. package/boat/doc-collector/src/docs-renderer.ts +14 -1
  7. package/boat/doc-collector/src/screenshots.ts +126 -0
  8. package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
  9. package/dist/boat/doc-collector/src/ai/tools.js +53 -20
  10. package/dist/boat/doc-collector/src/cli.js +3 -0
  11. package/dist/boat/doc-collector/src/config.js +3 -0
  12. package/dist/boat/doc-collector/src/docbot.js +19 -4
  13. package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
  14. package/dist/boat/doc-collector/src/screenshots.js +90 -0
  15. package/dist/package.json +2 -2
  16. package/dist/src/action.js +26 -23
  17. package/dist/src/ai/conversation.js +1 -1
  18. package/dist/src/ai/historian/codeceptjs.js +3 -2
  19. package/dist/src/ai/historian/experience.js +48 -6
  20. package/dist/src/ai/historian/playwright.js +2 -1
  21. package/dist/src/ai/historian/utils.js +1 -19
  22. package/dist/src/ai/historian.js +1 -1
  23. package/dist/src/ai/provider.js +3 -2
  24. package/dist/src/ai/quartermaster.js +2 -2
  25. package/dist/src/ai/tester.js +3 -0
  26. package/dist/src/ai/tools.js +0 -1
  27. package/dist/src/experience-tracker.js +1 -1
  28. package/dist/src/explorbot.js +7 -1
  29. package/dist/src/explorer.js +30 -27
  30. package/dist/src/utils/browser-errors.js +5 -0
  31. package/dist/src/utils/page-readiness.js +48 -0
  32. package/dist/src/utils/step-analyzer.js +68 -0
  33. package/package.json +2 -2
  34. package/src/action.ts +24 -26
  35. package/src/ai/conversation.ts +1 -1
  36. package/src/ai/historian/codeceptjs.ts +3 -2
  37. package/src/ai/historian/experience.ts +51 -6
  38. package/src/ai/historian/playwright.ts +2 -1
  39. package/src/ai/historian/utils.ts +1 -21
  40. package/src/ai/historian.ts +1 -1
  41. package/src/ai/provider.ts +3 -2
  42. package/src/ai/quartermaster.ts +2 -2
  43. package/src/ai/tester.ts +3 -0
  44. package/src/ai/tools.ts +0 -1
  45. package/src/config.ts +1 -0
  46. package/src/experience-tracker.ts +1 -1
  47. package/src/explorbot.ts +7 -1
  48. package/src/explorer.ts +28 -27
  49. package/src/utils/browser-errors.ts +6 -0
  50. package/src/utils/page-readiness.ts +59 -0
  51. package/src/utils/step-analyzer.ts +73 -0
@@ -45,7 +45,7 @@ class Documentarian {
45
45
  try {
46
46
  tag('info').log('Starting interactive exploration...');
47
47
 
48
- const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research);
48
+ const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research, this.config);
49
49
  const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
50
50
  if (meaningfulInteractions.length > 0) {
51
51
  tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
@@ -240,10 +240,15 @@ class Documentarian {
240
240
  }
241
241
 
242
242
  private normalizeDocumentation(documentation: PageDocumentation, _state: WebPageState, _research: string): PageDocumentation {
243
- const qualityNotes = this.evaluateDocumentationQuality(documentation);
243
+ const normalized = { ...documentation };
244
+ if (!normalized.interactions) {
245
+ normalized.interactions = undefined;
246
+ }
247
+
248
+ const qualityNotes = this.evaluateDocumentationQuality(normalized);
244
249
 
245
250
  return {
246
- ...documentation,
251
+ ...normalized,
247
252
  qualityNotes,
248
253
  };
249
254
  }
@@ -321,36 +326,55 @@ const stateTransitionSchema = z.object({
321
326
  action: z.string(),
322
327
  before: z.string(),
323
328
  after: z.string(),
324
- targetUrl: z.string().optional(),
325
- discoveredUrls: z.array(z.string()).optional(),
326
- newCapabilities: z.array(z.string()).optional(),
329
+ targetUrl: z.string().nullable(),
330
+ discoveredUrls: z.array(z.string()).nullable(),
331
+ newCapabilities: z.array(z.string()).nullable(),
327
332
  element: z
328
333
  .object({
329
334
  role: z.string(),
330
335
  name: z.string(),
331
336
  section: z.string(),
332
- container: z.string().optional(),
333
- locator: z.string().optional(),
337
+ container: z.string().nullable(),
338
+ locator: z.string().nullable(),
334
339
  })
335
- .optional(),
340
+ .nullable(),
336
341
  changes: z
337
342
  .object({
338
343
  urlChanged: z.boolean(),
339
344
  newElements: z.number(),
340
345
  removedElements: z.number(),
341
346
  })
342
- .optional(),
347
+ .nullable(),
343
348
  });
344
349
 
345
350
  const pageDocumentationSchema = z.object({
346
351
  summary: z.string(),
347
352
  can: z.array(capabilitySchema),
348
353
  might: z.array(capabilitySchema),
349
- interactions: z.array(stateTransitionSchema).optional(),
354
+ interactions: z.array(stateTransitionSchema).nullable(),
350
355
  });
351
356
 
352
- type StateTransition = z.infer<typeof stateTransitionSchema>;
353
- type PageDocumentation = z.infer<typeof pageDocumentationSchema> & {
357
+ type StateTransition = {
358
+ action: string;
359
+ before: string;
360
+ after: string;
361
+ targetUrl?: string | null;
362
+ discoveredUrls?: string[] | null;
363
+ newCapabilities?: string[] | null;
364
+ element?: {
365
+ role: string;
366
+ name: string;
367
+ section: string;
368
+ container?: string | null;
369
+ locator?: string | null;
370
+ } | null;
371
+ changes?: {
372
+ urlChanged: boolean;
373
+ newElements: number;
374
+ removedElements: number;
375
+ } | null;
376
+ };
377
+ type PageDocumentation = Omit<z.infer<typeof pageDocumentationSchema>, 'interactions'> & {
354
378
  interactions?: StateTransition[];
355
379
  qualityNotes?: string[];
356
380
  };
@@ -1,6 +1,7 @@
1
1
  import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
2
2
  import type Explorer from '../../../../src/explorer.ts';
3
3
  import type { WebPageState } from '../../../../src/state-manager.ts';
4
+ import type { DocbotConfig } from '../config.ts';
4
5
 
5
6
  export interface DocStateTransition {
6
7
  action: string;
@@ -34,23 +35,25 @@ interface InteractionChanges {
34
35
  removedElements: number;
35
36
  }
36
37
 
37
- const MAX_PRIMARY_CANDIDATES = 3;
38
- const MAX_INTERACTIONS = 5;
38
+ const DEFAULT_MAX_PRIMARY_CANDIDATES = 3;
39
+ const DEFAULT_MAX_INTERACTIONS = 5;
39
40
  const MAX_LINKS = 15;
40
41
  const DEFAULT_WAIT_MS = 700;
41
42
  const TAB_WAIT_MS = 500;
43
+ const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
42
44
 
43
- export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string): Promise<DocStateTransition[]> {
45
+ export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}): Promise<DocStateTransition[]> {
44
46
  const sections = parseResearchSections(research);
45
47
  const transitions: DocStateTransition[] = [];
48
+ const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
46
49
  const tabGroup = findTabGroup(sections);
47
50
 
48
51
  if (tabGroup) {
49
- transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url)));
52
+ transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions)));
50
53
  }
51
54
 
52
- for (const candidate of findActionCandidates(sections)) {
53
- if (transitions.length >= MAX_INTERACTIONS) {
55
+ for (const candidate of findActionCandidates(sections, config)) {
56
+ if (transitions.length >= maxInteractions) {
54
57
  break;
55
58
  }
56
59
 
@@ -65,18 +68,22 @@ export async function collectDocInteractions(explorer: Explorer, state: WebPageS
65
68
  return transitions;
66
69
  }
67
70
 
68
- export function pickDocActionCandidates(research: string): Array<{ label: string; role: InteractionCandidate['role']; section: string }> {
69
- return findActionCandidates(parseResearchSections(research)).map((candidate) => ({
71
+ export function pickDocActionCandidates(research: string, config: DocbotConfig = {}): Array<{ label: string; role: InteractionCandidate['role']; section: string }> {
72
+ return findActionCandidates(parseResearchSections(research), config).map((candidate) => ({
70
73
  label: candidate.element.name.trim(),
71
74
  role: candidate.role,
72
75
  section: candidate.sectionName,
73
76
  }));
74
77
  }
75
78
 
76
- async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string): Promise<DocStateTransition[]> {
79
+ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number): Promise<DocStateTransition[]> {
77
80
  const transitions: DocStateTransition[] = [];
78
81
 
79
82
  for (const element of tabGroup.elements) {
83
+ if (transitions.length >= maxInteractions) {
84
+ break;
85
+ }
86
+
80
87
  const transition = await executeInteraction(
81
88
  explorer,
82
89
  {
@@ -241,23 +248,21 @@ function findTabGroup(sections: ReturnType<typeof parseResearchSections>): { ele
241
248
  return null;
242
249
  }
243
250
 
244
- function findActionCandidates(sections: ReturnType<typeof parseResearchSections>): InteractionCandidate[] {
251
+ function findActionCandidates(sections: ReturnType<typeof parseResearchSections>, config: DocbotConfig): InteractionCandidate[] {
245
252
  const candidates: InteractionCandidate[] = [];
246
253
  const seen = new Set<string>();
247
254
  const navigationLabels = collectNavigationLabels(sections);
255
+ const maxPrimaryCandidates = getPositiveConfigNumber(config.docs?.maxPrimaryCandidates, DEFAULT_MAX_PRIMARY_CANDIDATES);
248
256
 
249
257
  for (const section of sections) {
250
258
  const sectionName = section.name.toLowerCase();
251
259
  const container = section.containerCss?.toLowerCase() || '';
252
- if (isOverlaySection(sectionName, container)) {
253
- continue;
254
- }
255
- if (isNavigationSection(sectionName)) {
260
+ if (isIgnoredSection(sectionName, container)) {
256
261
  continue;
257
262
  }
258
263
 
259
264
  for (const element of section.elements) {
260
- const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels);
265
+ const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels, config);
261
266
  if (!candidate) {
262
267
  continue;
263
268
  }
@@ -272,10 +277,10 @@ function findActionCandidates(sections: ReturnType<typeof parseResearchSections>
272
277
  }
273
278
  }
274
279
 
275
- return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, MAX_PRIMARY_CANDIDATES);
280
+ return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, maxPrimaryCandidates);
276
281
  }
277
282
 
278
- function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set<string>): InteractionCandidate | null {
283
+ function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set<string>, config: DocbotConfig): InteractionCandidate | null {
279
284
  const role = getElementRole(element);
280
285
  if (role !== 'link' && role !== 'button' && role !== 'tab') {
281
286
  return null;
@@ -283,7 +288,10 @@ function toInteractionCandidate(element: ResearchElement, sectionName: string, c
283
288
  if (!hasUsableName(element)) {
284
289
  return null;
285
290
  }
286
- if (isShellLocator(element.css) || isShellLocator(element.xpath) || isShellLocator(container)) {
291
+ if (isPageShellContainer(element.css) || isPageShellContainer(element.xpath)) {
292
+ return null;
293
+ }
294
+ if (isDestructiveAction(element, config)) {
287
295
  return null;
288
296
  }
289
297
  if (role === 'link' && navigationLabels.has(normalizeCandidateLabel(element.name))) {
@@ -454,6 +462,20 @@ function isNavigationSection(sectionName: string): boolean {
454
462
  return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName);
455
463
  }
456
464
 
465
+ function isContentControlSection(sectionName: string): boolean {
466
+ return /(content|control|filter|toolbar|action|list|data)/i.test(sectionName);
467
+ }
468
+
469
+ function isIgnoredSection(sectionName: string, container: string): boolean {
470
+ if (isOverlaySection(sectionName, container)) {
471
+ return true;
472
+ }
473
+ if (isContentControlSection(sectionName)) {
474
+ return false;
475
+ }
476
+ return isNavigationSection(sectionName) || isPageShellContainer(container);
477
+ }
478
+
457
479
  function isOverlaySection(sectionName: string, container: string): boolean {
458
480
  return /(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container);
459
481
  }
@@ -483,12 +505,12 @@ function scoreCandidate(candidate: InteractionCandidate): number {
483
505
  return score;
484
506
  }
485
507
 
486
- function isShellLocator(locator: string | null | undefined): boolean {
508
+ function isPageShellContainer(locator: string | null | undefined): boolean {
487
509
  if (!locator) {
488
510
  return false;
489
511
  }
490
512
 
491
- return /(nav\[role="navigation"\]|header|menu|breadcrumb|footer)/i.test(locator);
513
+ return /(^|[\s>+~,.#\[])(nav|navigation|mainnav|header|menu|breadcrumb|footer)([\s>+~,.#\]_-]|$)/i.test(locator);
492
514
  }
493
515
 
494
516
  function collectNavigationLabels(sections: ReturnType<typeof parseResearchSections>): Set<string> {
@@ -515,6 +537,24 @@ function normalizeCandidateLabel(label: string): string {
515
537
  return label.trim().toLowerCase();
516
538
  }
517
539
 
540
+ function isDestructiveAction(element: ResearchElement, config: DocbotConfig): boolean {
541
+ const label = normalizeCandidateLabel(element.name);
542
+ const deniedLabels = config.docs?.deniedActionLabels || DEFAULT_DENIED_ACTION_LABELS;
543
+ if (deniedLabels.some((denied) => label.includes(normalizeCandidateLabel(denied)))) {
544
+ return true;
545
+ }
546
+
547
+ const locator = `${element.css || ''} ${element.xpath || ''}`.toLowerCase();
548
+ return deniedLabels.some((denied) => locator.includes(normalizeCandidateLabel(denied)));
549
+ }
550
+
551
+ function getPositiveConfigNumber(value: number | undefined, fallback: number): number {
552
+ if (!value || value <= 0) {
553
+ return fallback;
554
+ }
555
+ return value;
556
+ }
557
+
518
558
  function limitInlineText(text: string, maxLength: number): string {
519
559
  const normalized = text.replace(/\s+/g, ' ').trim();
520
560
  if (normalized.length <= maxLength) {
@@ -101,6 +101,9 @@ export function createDocsCommands(name = 'docs'): Command {
101
101
  includePaths: [],
102
102
  excludePaths: [],
103
103
  deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
104
+ deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
105
+ maxPrimaryCandidates: 3,
106
+ maxInteractions: 5,
104
107
  minCanActions: 1,
105
108
  minInteractiveElements: 3,
106
109
  // prompt: 'Add domain-specific documentation guidance here',
@@ -120,6 +120,9 @@ class DocbotConfigParser {
120
120
  includePaths: [],
121
121
  excludePaths: [],
122
122
  deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
123
+ deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
124
+ maxPrimaryCandidates: 3,
125
+ maxInteractions: 5,
123
126
  minCanActions: 1,
124
127
  minInteractiveElements: 3,
125
128
  },
@@ -154,6 +157,10 @@ interface DocbotConfig {
154
157
  includePaths?: string[];
155
158
  excludePaths?: string[];
156
159
  deniedPathSegments?: string[];
160
+ deniedActionLabels?: string[];
161
+ maxPrimaryCandidates?: number;
162
+ maxInteractions?: number;
163
+ maxSectionScreenshots?: number;
157
164
  minCanActions?: number;
158
165
  minInteractiveElements?: number;
159
166
  interactive?: boolean;
@@ -3,13 +3,14 @@ import path from 'node:path';
3
3
  import { ExplorBot, type ExplorBotOptions } from '../../../src/explorbot.ts';
4
4
  import type { Link, WebPageState } from '../../../src/state-manager.ts';
5
5
  import { normalizeUrl } from '../../../src/state-manager.ts';
6
- import { sanitizeFilename } from '../../../src/utils/strings.ts';
7
6
  import { tag } from '../../../src/utils/logger.ts';
7
+ import { sanitizeFilename } from '../../../src/utils/strings.ts';
8
8
  import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
9
9
  import { type DocbotConfig, DocbotConfigParser } from './config.ts';
10
- import { type DocumentedPage, renderPageDocumentation, renderSpecIndex, type SkippedPage } from './docs-renderer.ts';
10
+ import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
11
11
  import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
12
12
  import { extractResearchNavigationTargets } from './research-navigation.ts';
13
+ import { type DocumentationScreenshot, captureDocumentationScreenshots } from './screenshots.ts';
13
14
 
14
15
  class DocBot {
15
16
  private explorBot: ExplorBot;
@@ -120,7 +121,7 @@ class DocBot {
120
121
  documented.add(pageKey);
121
122
  continue;
122
123
  }
123
- const filePath = this.savePageDocumentation(state, documentation);
124
+ const filePath = await this.savePageDocumentation(state, documentation, research);
124
125
 
125
126
  pages.push({
126
127
  url: state.url,
@@ -397,12 +398,25 @@ class DocBot {
397
398
  return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 0);
398
399
  }
399
400
 
400
- private savePageDocumentation(state: WebPageState, documentation: PageDocumentation): string {
401
+ private async savePageDocumentation(state: WebPageState, documentation: PageDocumentation, research: string): Promise<string> {
401
402
  const pagePath = this.getPageFilePath(state.url);
402
- writeFileSync(pagePath, renderPageDocumentation(state, documentation), 'utf8');
403
+ const screenshots = await this.captureScreenshots(state, research, pagePath);
404
+ writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots), 'utf8');
403
405
  return pagePath;
404
406
  }
405
407
 
408
+ private async captureScreenshots(state: WebPageState, research: string, pagePath: string): Promise<DocumentationScreenshot[]> {
409
+ if (!this.shouldUseScreenshots()) {
410
+ return [];
411
+ }
412
+
413
+ return captureDocumentationScreenshots(this.explorBot.getExplorer(), state, research, {
414
+ pageFilePath: pagePath,
415
+ screenshotsDir: this.getScreenshotsDir(),
416
+ config: this.config,
417
+ });
418
+ }
419
+
406
420
  private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): string {
407
421
  const indexPath = path.join(this.configParser.getOutputDir(), 'spec.md');
408
422
  writeFileSync(indexPath, renderSpecIndex(this.configParser.getOutputDir(), startPath, pages, skipped, maxPages), 'utf8');
@@ -413,6 +427,10 @@ class DocBot {
413
427
  return path.join(this.configParser.getOutputDir(), 'pages');
414
428
  }
415
429
 
430
+ private getScreenshotsDir(): string {
431
+ return path.join(this.configParser.getOutputDir(), 'screenshots');
432
+ }
433
+
416
434
  private getPageFilePath(pageUrl: string): string {
417
435
  const normalized = normalizeUrl(pageUrl || '/');
418
436
  const baseName = sanitizeFilename(normalized || 'root');
@@ -1,8 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import type { WebPageState } from '../../../src/state-manager.ts';
3
3
  import type { PageDocumentation, StateTransition } from './ai/documentarian.ts';
4
+ import type { DocumentationScreenshot } from './screenshots.ts';
4
5
 
5
- function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation): string {
6
+ function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = []): string {
6
7
  const lines: string[] = [];
7
8
  lines.push(`# ${state.url}`);
8
9
  lines.push('');
@@ -17,6 +18,18 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
17
18
  lines.push(ensureSentence(documentation.summary));
18
19
  lines.push('');
19
20
 
21
+ if (screenshots.length > 0) {
22
+ lines.push('## Screenshots');
23
+ lines.push('');
24
+ for (const screenshot of screenshots) {
25
+ lines.push(`![${normalizeInlineText(screenshot.title)}](${screenshot.relativePath})`);
26
+ if (screenshot.selector) {
27
+ lines.push(`Section: \`${screenshot.selector}\``);
28
+ }
29
+ lines.push('');
30
+ }
31
+ }
32
+
20
33
  const interactions = documentation.interactions;
21
34
  if (interactions && interactions.length > 0) {
22
35
  lines.push('## State Transitions');
@@ -0,0 +1,126 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseResearchSections } from '../../../src/ai/researcher/parser.ts';
4
+ import type Explorer from '../../../src/explorer.ts';
5
+ import type { WebPageState } from '../../../src/state-manager.ts';
6
+ import { safeFilename, sanitizeFilename } from '../../../src/utils/strings.ts';
7
+ import type { DocbotConfig } from './config.ts';
8
+
9
+ const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
10
+
11
+ export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
12
+ const page = explorer.playwrightHelper?.page;
13
+ if (!page) {
14
+ return [];
15
+ }
16
+
17
+ mkdirSync(options.screenshotsDir, { recursive: true });
18
+
19
+ const screenshots: DocumentationScreenshot[] = [];
20
+ const pageName = sanitizeFilename(state.url || 'page') || 'page';
21
+ const fullPage = await captureFullPageScreenshot(page, pageName, options);
22
+ if (fullPage) {
23
+ screenshots.push(fullPage);
24
+ }
25
+
26
+ const maxSections = getMaxSectionScreenshots(options.config);
27
+ for (const section of getScreenshotSections(research).slice(0, maxSections)) {
28
+ const screenshot = await captureSectionScreenshot(page, pageName, section, options);
29
+ if (!screenshot) {
30
+ continue;
31
+ }
32
+ screenshots.push(screenshot);
33
+ }
34
+
35
+ return screenshots;
36
+ }
37
+
38
+ export function getScreenshotSections(research: string): ScreenshotSection[] {
39
+ const sections: ScreenshotSection[] = [];
40
+ const seen = new Set<string>();
41
+
42
+ for (const section of parseResearchSections(research)) {
43
+ if (!section.containerCss) {
44
+ continue;
45
+ }
46
+ if (section.elements.length === 0) {
47
+ continue;
48
+ }
49
+ if (seen.has(section.containerCss)) {
50
+ continue;
51
+ }
52
+ seen.add(section.containerCss);
53
+ sections.push({
54
+ title: section.name,
55
+ selector: section.containerCss,
56
+ });
57
+ }
58
+
59
+ return sections;
60
+ }
61
+
62
+ async function captureFullPageScreenshot(page: any, pageName: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
63
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
64
+ try {
65
+ await page.screenshot({ path: filePath, fullPage: true });
66
+ } catch {
67
+ return null;
68
+ }
69
+
70
+ return {
71
+ title: 'Page screenshot',
72
+ path: filePath,
73
+ relativePath: toMarkdownPath(options.pageFilePath, filePath),
74
+ kind: 'page',
75
+ };
76
+ }
77
+
78
+ async function captureSectionScreenshot(page: any, pageName: string, section: ScreenshotSection, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
79
+ const sectionName = sanitizeFilename(section.title) || 'section';
80
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${sectionName}`, '.png'));
81
+
82
+ try {
83
+ await page.locator(section.selector).first().screenshot({ path: filePath });
84
+ } catch {
85
+ return null;
86
+ }
87
+
88
+ return {
89
+ title: section.title,
90
+ path: filePath,
91
+ relativePath: toMarkdownPath(options.pageFilePath, filePath),
92
+ kind: 'section',
93
+ selector: section.selector,
94
+ };
95
+ }
96
+
97
+ function getMaxSectionScreenshots(config: DocbotConfig): number {
98
+ const configured = config.docs?.maxSectionScreenshots;
99
+ if (configured && configured > 0) {
100
+ return configured;
101
+ }
102
+ return DEFAULT_MAX_SECTION_SCREENSHOTS;
103
+ }
104
+
105
+ function toMarkdownPath(pageFilePath: string, assetPath: string): string {
106
+ return path.relative(path.dirname(pageFilePath), assetPath).replaceAll('\\', '/');
107
+ }
108
+
109
+ export interface DocumentationScreenshot {
110
+ title: string;
111
+ path: string;
112
+ relativePath: string;
113
+ kind: 'page' | 'section';
114
+ selector?: string;
115
+ }
116
+
117
+ interface DocumentationScreenshotOptions {
118
+ pageFilePath: string;
119
+ screenshotsDir: string;
120
+ config: DocbotConfig;
121
+ }
122
+
123
+ interface ScreenshotSection {
124
+ title: string;
125
+ selector: string;
126
+ }
@@ -34,7 +34,7 @@ class Documentarian {
34
34
  async documentWithInteraction(state, research) {
35
35
  try {
36
36
  tag('info').log('Starting interactive exploration...');
37
- const deterministicInteractions = await collectDocInteractions(this.explorer, state, research);
37
+ const deterministicInteractions = await collectDocInteractions(this.explorer, state, research, this.config);
38
38
  const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
39
39
  if (meaningfulInteractions.length > 0) {
40
40
  tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
@@ -210,9 +210,13 @@ class Documentarian {
210
210
  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');
211
211
  }
212
212
  normalizeDocumentation(documentation, _state, _research) {
213
- const qualityNotes = this.evaluateDocumentationQuality(documentation);
213
+ const normalized = { ...documentation };
214
+ if (!normalized.interactions) {
215
+ normalized.interactions = undefined;
216
+ }
217
+ const qualityNotes = this.evaluateDocumentationQuality(normalized);
214
218
  return {
215
- ...documentation,
219
+ ...normalized,
216
220
  qualityNotes,
217
221
  };
218
222
  }
@@ -277,30 +281,30 @@ const stateTransitionSchema = z.object({
277
281
  action: z.string(),
278
282
  before: z.string(),
279
283
  after: z.string(),
280
- targetUrl: z.string().optional(),
281
- discoveredUrls: z.array(z.string()).optional(),
282
- newCapabilities: z.array(z.string()).optional(),
284
+ targetUrl: z.string().nullable(),
285
+ discoveredUrls: z.array(z.string()).nullable(),
286
+ newCapabilities: z.array(z.string()).nullable(),
283
287
  element: z
284
288
  .object({
285
289
  role: z.string(),
286
290
  name: z.string(),
287
291
  section: z.string(),
288
- container: z.string().optional(),
289
- locator: z.string().optional(),
292
+ container: z.string().nullable(),
293
+ locator: z.string().nullable(),
290
294
  })
291
- .optional(),
295
+ .nullable(),
292
296
  changes: z
293
297
  .object({
294
298
  urlChanged: z.boolean(),
295
299
  newElements: z.number(),
296
300
  removedElements: z.number(),
297
301
  })
298
- .optional(),
302
+ .nullable(),
299
303
  });
300
304
  const pageDocumentationSchema = z.object({
301
305
  summary: z.string(),
302
306
  can: z.array(capabilitySchema),
303
307
  might: z.array(capabilitySchema),
304
- interactions: z.array(stateTransitionSchema).optional(),
308
+ interactions: z.array(stateTransitionSchema).nullable(),
305
309
  });
306
310
  export { Documentarian };