explorbot 0.4.1 → 0.4.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 (128) hide show
  1. package/bin/explorbot-cli.ts +8 -3
  2. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  5. package/boat/doc-collector/src/ai/tools.ts +1 -1
  6. package/boat/doc-collector/src/cli.ts +18 -2
  7. package/boat/doc-collector/src/config.ts +3 -0
  8. package/boat/doc-collector/src/docbot.ts +56 -11
  9. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  10. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  11. package/boat/doc-collector/src/screenshots.ts +106 -3
  12. package/boat/doc-collector/src/template-dedup.ts +31 -0
  13. package/boat/prima/src/cli.ts +3 -0
  14. package/boat/prima/src/prima.ts +3 -4
  15. package/dist/bin/explorbot-cli.js +9 -3
  16. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  17. package/dist/boat/api-tester/src/cli.js +2 -0
  18. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  19. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  20. package/dist/boat/doc-collector/src/cli.js +15 -2
  21. package/dist/boat/doc-collector/src/config.js +1 -0
  22. package/dist/boat/doc-collector/src/docbot.js +52 -10
  23. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  24. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  25. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  26. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  27. package/dist/boat/prima/src/cli.js +2 -0
  28. package/dist/boat/prima/src/prima.js +3 -4
  29. package/dist/package.json +1 -1
  30. package/dist/src/action-result.d.ts +2 -2
  31. package/dist/src/action-result.js +15 -17
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +15 -15
  34. package/dist/src/ai/fisherman/request-haul.d.ts +11 -0
  35. package/dist/src/ai/fisherman/request-haul.js +28 -0
  36. package/dist/src/ai/fisherman-tools.d.ts +10 -1
  37. package/dist/src/ai/fisherman-tools.js +35 -31
  38. package/dist/src/ai/fisherman.d.ts +3 -2
  39. package/dist/src/ai/fisherman.js +8 -6
  40. package/dist/src/ai/pilot.js +21 -16
  41. package/dist/src/ai/provider.js +8 -14
  42. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  43. package/dist/src/ai/researcher/coordinates.js +13 -1
  44. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  45. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  46. package/dist/src/ai/researcher.js +1 -1
  47. package/dist/src/ai/rules.js +8 -0
  48. package/dist/src/ai/tester.js +20 -20
  49. package/dist/src/ai/tools.d.ts +1 -1
  50. package/dist/src/ai/tools.js +18 -74
  51. package/dist/src/api/request-store.d.ts +1 -5
  52. package/dist/src/api/request-store.js +12 -22
  53. package/dist/src/commands/config-command.js +2 -9
  54. package/dist/src/commands/index.js +2 -0
  55. package/dist/src/commands/init-command.js +3 -7
  56. package/dist/src/commands/plans-command.d.ts +4 -1
  57. package/dist/src/commands/plans-command.js +4 -4
  58. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  59. package/dist/src/commands/recommended-models-command.js +85 -0
  60. package/dist/src/config.d.ts +2 -0
  61. package/dist/src/config.js +7 -2
  62. package/dist/src/experience-tracker.js +1 -1
  63. package/dist/src/explorbot.js +3 -3
  64. package/dist/src/global-config.d.ts +2 -0
  65. package/dist/src/global-config.js +6 -0
  66. package/dist/src/state-manager.d.ts +3 -3
  67. package/dist/src/state-manager.js +8 -8
  68. package/dist/src/test-plan.d.ts +1 -0
  69. package/dist/src/test-plan.js +13 -9
  70. package/dist/src/utils/aria.d.ts +1 -1
  71. package/dist/src/utils/aria.js +23 -2
  72. package/dist/src/utils/cli-section.d.ts +1 -0
  73. package/dist/src/utils/cli-section.js +12 -0
  74. package/dist/src/utils/html-diff.d.ts +1 -0
  75. package/dist/src/utils/html-diff.js +5 -5
  76. package/dist/src/utils/html.d.ts +2 -0
  77. package/dist/src/utils/html.js +5 -4
  78. package/dist/src/utils/overlay.d.ts +19 -55
  79. package/dist/src/utils/overlay.js +94 -148
  80. package/dist/src/utils/region.d.ts +41 -0
  81. package/dist/src/utils/region.js +99 -0
  82. package/dist/src/utils/similarity.d.ts +1 -0
  83. package/dist/src/utils/similarity.js +11 -0
  84. package/dist/src/utils/url-matcher.d.ts +1 -1
  85. package/dist/src/utils/url-matcher.js +4 -4
  86. package/docs/doc-collection/basics.md +11 -5
  87. package/docs/doc-collection/crawling.md +21 -1
  88. package/docs/reference/commands.md +3 -0
  89. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  90. package/docs/workflow/application-spec.md +4 -0
  91. package/package.json +1 -1
  92. package/src/action-result.ts +15 -16
  93. package/src/action.ts +16 -15
  94. package/src/ai/fisherman/request-haul.ts +32 -0
  95. package/src/ai/fisherman-tools.ts +38 -32
  96. package/src/ai/fisherman.ts +8 -7
  97. package/src/ai/pilot.ts +19 -14
  98. package/src/ai/provider.ts +8 -12
  99. package/src/ai/researcher/coordinates.ts +13 -1
  100. package/src/ai/researcher/deep-analysis.ts +9 -9
  101. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  102. package/src/ai/researcher.ts +1 -1
  103. package/src/ai/rules.ts +8 -0
  104. package/src/ai/tester.ts +18 -18
  105. package/src/ai/tools.ts +18 -86
  106. package/src/api/request-store.ts +13 -27
  107. package/src/commands/config-command.ts +2 -7
  108. package/src/commands/index.ts +2 -0
  109. package/src/commands/init-command.ts +4 -11
  110. package/src/commands/plans-command.ts +5 -5
  111. package/src/commands/recommended-models-command.ts +92 -0
  112. package/src/config.ts +9 -2
  113. package/src/experience-tracker.ts +1 -1
  114. package/src/explorbot.ts +3 -3
  115. package/src/global-config.ts +8 -0
  116. package/src/state-manager.ts +10 -9
  117. package/src/test-plan.ts +12 -10
  118. package/src/utils/aria.ts +26 -3
  119. package/src/utils/cli-section.ts +12 -0
  120. package/src/utils/html-diff.ts +6 -6
  121. package/src/utils/html.ts +6 -4
  122. package/src/utils/overlay.ts +105 -192
  123. package/src/utils/region.ts +132 -0
  124. package/src/utils/similarity.ts +9 -0
  125. package/src/utils/url-matcher.ts +3 -3
  126. package/dist/src/utils/request-map.d.ts +0 -7
  127. package/dist/src/utils/request-map.js +0 -16
  128. package/src/utils/request-map.ts +0 -19
@@ -8,6 +8,7 @@ import figureSet from 'figures';
8
8
  import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { flushTelemetry } from '../src/ai/provider.js';
11
+ import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
11
12
  import { App } from '../src/components/App.js';
12
13
  import { StatusPane } from '../src/components/StatusPane.js';
13
14
  import { knowledgeOption, wsOption } from '../src/commands/options/index.js';
@@ -336,12 +337,14 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
336
337
  indexArg = planfile;
337
338
  }
338
339
 
339
- const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
340
+ const peeked = Plan.loadFromFile(planfileArg);
340
341
 
341
- const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
342
+ const explorBot = new ExplorBot(buildExplorBotOptions(peeked?.startUrl, options));
342
343
  await explorBot.start();
343
344
 
344
- const plan = explorBot.loadPlan(planfileArg);
345
+ let plan = peeked;
346
+ if (plan) explorBot.setCurrentPlan(plan);
347
+ if (!plan) plan = explorBot.loadPlan(planfileArg);
345
348
  const pending = plan.getPendingTests();
346
349
  log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
347
350
 
@@ -470,6 +473,8 @@ program
470
473
  }
471
474
  });
472
475
 
476
+ RecommendedModelsCommand.register(program);
477
+
473
478
  program
474
479
  .command('init')
475
480
  .description('Initialize configuration for a project or for this machine')
@@ -66,7 +66,7 @@ export function createCurlerTools(apiClient: ApiClient, requestState: RequestSto
66
66
  queryParams: input.queryParams,
67
67
  });
68
68
 
69
- requestState.addRequest(result);
69
+ requestState.addMadeRequest(result);
70
70
 
71
71
  if (result.error) {
72
72
  tag('error').log(`${input.method} ${input.path} > Network error: ${result.error}`);
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Command } from 'commander';
4
4
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
5
+ import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
5
6
  import { listSites } from '../../../src/global-config.ts';
6
7
  import { setPreserveConsoleLogs } from '../../../src/utils/logger.ts';
7
8
  import { getStyles } from './ai/chief/styles.ts';
@@ -109,6 +110,8 @@ export function createApiCommands(name = 'api'): Command {
109
110
  }
110
111
  });
111
112
 
113
+ RecommendedModelsCommand.register(cmd);
114
+
112
115
  addCommonOptions(cmd.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1-3, *')).action(async (planfile, index, options) => {
113
116
  setPreserveConsoleLogs(true);
114
117
  try {
@@ -4,7 +4,9 @@ import type { AIProvider } from '../../../../src/ai/provider.ts';
4
4
  import type Explorer from '../../../../src/explorer.ts';
5
5
  import type { StateManager } from '../../../../src/state-manager.ts';
6
6
  import type { WebPageState } from '../../../../src/state-manager.ts';
7
+ import { parseAriaLocator } from '../../../../src/utils/aria.ts';
7
8
  import { tag } from '../../../../src/utils/logger.ts';
9
+ import { parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
8
10
  import type { DocbotConfig } from '../config.ts';
9
11
  import { type CaptureInteractionState, type DocStateTransition, collectDocInteractions } from './tools.ts';
10
12
 
@@ -182,6 +184,9 @@ class Documentarian {
182
184
  Only list capabilities that are grounded in the provided page research.
183
185
  Put actions into "can" only when there is direct evidence in the page context.
184
186
  Put actions into "might" only when the UI strongly suggests a capability but proof is incomplete.
187
+ Going to another page is navigation, not a capability. "can" and "might" list only actions the user performs on this page; links and menus are documented in a separate navigation section, never here.
188
+ Raw interaction observations that change the URL are navigation evidence only. Never turn them into "can" or "might" actions.
189
+ Exclude capabilities supplied by unrelated embedded support, marketing, consent, or feedback widgets. Include an embedded interface only when it is part of the current page's documented purpose.
185
190
  Describe each action from the end-user perspective.
186
191
  Be explicit about scope:
187
192
  - one item
@@ -189,7 +194,7 @@ class Documentarian {
189
194
  - bulk operations
190
195
  - all items
191
196
  - page-level
192
- Avoid implementation details, selectors, and QA wording.
197
+ Avoid implementation details, selectors, and QA wording — except the machine-consumed element field, which is a locator, not prose.
193
198
  Avoid duplicate actions with different phrasing.
194
199
  </rules>
195
200
 
@@ -241,6 +246,8 @@ class Documentarian {
241
246
  - action: concise user-facing capability phrased as "user can ..."
242
247
  - scope: one of one item, list of items, bulk operations, all items, page-level
243
248
  - evidence: short reason based on visible UI or research
249
+ - element: required for every "can" and "might" action. Return the proving or suggesting control's locator copied verbatim from the page research table — the value from the ARIA column, or from the CSS column when the ARIA column is empty. Return null only when no single element supports the action. It is machine metadata used for validation and never shown as prose.
250
+ Never copy the human-readable Element description from interaction_observations into element. It is not a locator.
244
251
  </output_requirements>
245
252
  `;
246
253
  }
@@ -274,8 +281,30 @@ class Documentarian {
274
281
  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');
275
282
  }
276
283
 
277
- private normalizeDocumentation(documentation: GeneratedPageDocumentation & Partial<Pick<PageDocumentation, 'interactions'>>, _state: WebPageState, _research: string): PageDocumentation {
284
+ private normalizeDocumentation(documentation: GeneratedPageDocumentation & Partial<Pick<PageDocumentation, 'interactions'>>, _state: WebPageState, research: string): PageDocumentation {
278
285
  const normalized = { ...documentation };
286
+ const navigationLocators = new Set<string>();
287
+ const researchLocators = new Set<string>();
288
+ for (const section of parseResearchSections(research)) {
289
+ for (const element of section.elements) {
290
+ const aria = element.aria ? `{ role: '${element.aria.role}', text: '${element.aria.text}' }` : null;
291
+ if (aria) researchLocators.add(this.locatorKey(aria));
292
+ if (element.css) researchLocators.add(this.locatorKey(element.css));
293
+ if (element.aria?.role !== 'link') continue;
294
+ if (aria) navigationLocators.add(this.locatorKey(aria));
295
+ if (element.css) navigationLocators.add(this.locatorKey(element.css));
296
+ }
297
+ }
298
+ normalized.can = normalized.can.filter((capability) => {
299
+ if (!capability.element) return true;
300
+ const key = this.locatorKey(capability.element);
301
+ return researchLocators.has(key) && !navigationLocators.has(key);
302
+ });
303
+ normalized.might = normalized.might.filter((capability) => {
304
+ if (!capability.element) return false;
305
+ const key = this.locatorKey(capability.element);
306
+ return researchLocators.has(key) && !navigationLocators.has(key);
307
+ });
279
308
  if (!normalized.interactions) {
280
309
  normalized.interactions = undefined;
281
310
  }
@@ -288,6 +317,18 @@ class Documentarian {
288
317
  });
289
318
  }
290
319
 
320
+ private locatorKey(locator: string): string {
321
+ const aria = parseAriaLocator(locator);
322
+ if (aria) return `aria:${aria.role}:${aria.text}`;
323
+ const trimmed = locator.trim();
324
+ const first = trimmed.at(0);
325
+ const last = trimmed.at(-1);
326
+ if (first === last && (first === '`' || first === '"' || first === "'")) {
327
+ return `css:${trimmed.slice(1, -1).trim()}`;
328
+ }
329
+ return `css:${trimmed}`;
330
+ }
331
+
291
332
  private evaluateDocumentationQuality(documentation: PageDocumentation): string[] {
292
333
  const notes: string[] = [];
293
334
 
@@ -355,6 +396,14 @@ const capabilitySchema = z.object({
355
396
  action: z.string(),
356
397
  scope: z.enum(['one item', 'list of items', 'bulk operations', 'all items', 'page-level']),
357
398
  evidence: z.string(),
399
+ element: z.string().nullable().optional(),
400
+ });
401
+
402
+ const generatedCapabilitySchema = z.object({
403
+ action: z.string(),
404
+ scope: z.enum(['one item', 'list of items', 'bulk operations', 'all items', 'page-level']),
405
+ evidence: z.string(),
406
+ element: z.string().nullable(),
358
407
  });
359
408
 
360
409
  const stateTransitionSchema = z.object({
@@ -397,11 +446,14 @@ const stateTransitionSchema = z.object({
397
446
 
398
447
  const generatedPageDocumentationSchema = z.object({
399
448
  summary: z.string(),
400
- can: z.array(capabilitySchema),
401
- might: z.array(capabilitySchema),
449
+ can: z.array(generatedCapabilitySchema),
450
+ might: z.array(generatedCapabilitySchema),
402
451
  });
403
452
 
404
- const pageDocumentationSchema = generatedPageDocumentationSchema.extend({
453
+ const pageDocumentationSchema = z.object({
454
+ summary: z.string(),
455
+ can: z.array(capabilitySchema),
456
+ might: z.array(capabilitySchema),
405
457
  interactions: z.array(stateTransitionSchema).optional(),
406
458
  qualityNotes: z.array(z.string()).optional(),
407
459
  });
@@ -225,7 +225,7 @@ function buildTransition(candidate: InteractionCandidate, beforeState: WebPageSt
225
225
  function describeTargetState(beforeState: WebPageState, afterState: WebPageState, candidate: InteractionCandidate): InteractionState {
226
226
  const beforeFocus = detectFocusArea(beforeState.ariaSnapshot || null);
227
227
  const afterFocus = detectFocusArea(afterState.ariaSnapshot || null);
228
- if (afterFocus.detected && (!beforeFocus.detected || beforeFocus.name !== afterFocus.name)) {
228
+ if (afterFocus.type && (!beforeFocus.type || beforeFocus.name !== afterFocus.name)) {
229
229
  return {
230
230
  kind: afterFocus.type || 'dialog',
231
231
  label: afterFocus.name || candidate.element.name.trim(),
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Command } from 'commander';
4
4
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
5
+ import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
5
6
  import { remote } from '../../../src/remote.ts';
6
7
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from '../../../src/utils/logger.ts';
7
8
  import { DocBot, type DocbotOptions } from './docbot.ts';
@@ -38,7 +39,14 @@ export function createDocsCommands(name = 'docs'): Command {
38
39
  const cmd = new Command(name);
39
40
  cmd.description('AI-powered website documentation collector');
40
41
 
41
- addCommonOptions(cmd.command('collect <path>').description('Crawl pages and generate documentation spec').option('--max-pages <count>', 'Maximum number of pages to document')).action(async (startPath, options) => {
42
+ addCommonOptions(
43
+ cmd
44
+ .command('collect <path>')
45
+ .description('Crawl pages and generate documentation spec')
46
+ .option('--max-pages <count>', 'Maximum number of pages to document')
47
+ .option('--no-collapse-template-pages', 'Visit every page even when its layout matches a documented page')
48
+ .option('--template-similarity <percent>', 'Structural similarity percent that counts pages as the same layout (default 90)')
49
+ ).action(async (startPath, options) => {
42
50
  setPreserveConsoleLogs(true);
43
51
 
44
52
  try {
@@ -52,8 +60,12 @@ export function createDocsCommands(name = 'docs'): Command {
52
60
  if (options.maxPages) {
53
61
  maxPages = Number.parseInt(options.maxPages, 10);
54
62
  }
63
+ let templateSimilarity: number | undefined;
64
+ if (options.templateSimilarity) {
65
+ templateSimilarity = Number.parseInt(options.templateSimilarity, 10);
66
+ }
55
67
 
56
- const result = await bot.collect(startPath, { maxPages });
68
+ const result = await bot.collect(startPath, { maxPages, collapseTemplatePages: options.collapseTemplatePages, templateSimilarity });
57
69
 
58
70
  console.log(`\nDocumented ${result.pages.length} page(s)`);
59
71
  console.log(`Skipped ${result.skipped.length} page(s)`);
@@ -83,6 +95,8 @@ export function createDocsCommands(name = 'docs'): Command {
83
95
  }
84
96
  });
85
97
 
98
+ RecommendedModelsCommand.register(cmd);
99
+
86
100
  cmd
87
101
  .command('init')
88
102
  .description('Initialize doc collector configuration')
@@ -112,6 +126,8 @@ export function createDocsCommands(name = 'docs'): Command {
112
126
  interactive: false,
113
127
  ignoreErrors: true,
114
128
  collapseDynamicPages: true,
129
+ collapseTemplatePages: true,
130
+ templateSimilarity: 90,
115
131
  scope: 'site',
116
132
  includePaths: [],
117
133
  excludePaths: [],
@@ -119,6 +119,7 @@ class DocbotConfigParser {
119
119
  interactive: false,
120
120
  ignoreErrors: true,
121
121
  collapseDynamicPages: true,
122
+ collapseTemplatePages: true,
122
123
  scope: 'site',
123
124
  includePaths: [],
124
125
  excludePaths: [],
@@ -157,6 +158,8 @@ interface DocbotConfig {
157
158
  ignoreErrors?: boolean | string[];
158
159
  prompt?: string;
159
160
  collapseDynamicPages?: boolean;
161
+ collapseTemplatePages?: boolean;
162
+ templateSimilarity?: number;
160
163
  scope?: 'site' | 'section' | 'subtree';
161
164
  includePaths?: string[];
162
165
  excludePaths?: string[];
@@ -11,8 +11,9 @@ import { type DocbotConfig, DocbotConfigParser } from './config.ts';
11
11
  import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
12
12
  import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
13
13
  import { extractResearchNavigationTargets } from './research-navigation.ts';
14
- import { type DocumentationScreenshot, captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
14
+ import { type DocumentationScreenshot, captureBeforeInteraction, captureDocumentationScreenshots, captureEvidenceScreenshots, captureInteractionScreenshot } from './screenshots.ts';
15
15
  import { renderMermaidBody } from './state-diagram.ts';
16
+ import { type TemplateRecord, buildTemplateRecord, findTemplateMatch } from './template-dedup.ts';
16
17
 
17
18
  class DocBot {
18
19
  private explorBot: ExplorBot;
@@ -60,6 +61,7 @@ class DocBot {
60
61
  const queue: string[] = [];
61
62
  const queued = new Set<string>();
62
63
  const documented = new Set<string>();
64
+ const templates: TemplateRecord[] = [];
63
65
  const pages: DocumentedPage[] = [];
64
66
  const skipped: SkippedPage[] = [];
65
67
  const baseUrl = this.explorBot.getConfig().playwright.url;
@@ -86,15 +88,6 @@ class DocBot {
86
88
  tag('info').log(`Collecting docs for ${this.toDisplayUrl(target, baseUrl)}`);
87
89
  await this.explorBot.visit(target);
88
90
 
89
- if (stateManager.isInDeadLoop()) {
90
- tag('warning').log('Dead loop detected during docs crawl, stopping collection');
91
- skipped.push({
92
- url: target,
93
- reason: 'dead loop detected during crawl',
94
- });
95
- break;
96
- }
97
-
98
91
  const state = this.explorBot.getCurrentState();
99
92
  if (!state) {
100
93
  skipped.push({
@@ -109,6 +102,22 @@ class DocBot {
109
102
  continue;
110
103
  }
111
104
 
105
+ if (this.shouldCollapseTemplates(opts.collapseTemplatePages)) {
106
+ const templateUrl = findTemplateMatch(state.ariaSnapshot ?? null, templates, this.getTemplateSimilarity(opts.templateSimilarity));
107
+ if (templateUrl) {
108
+ skipped.push({
109
+ url: state.url,
110
+ reason: `same layout as ${templateUrl} (only content differs)`,
111
+ });
112
+ documented.add(pageKey);
113
+ for (const nextPath of this.extractNextPaths(state, baseUrl, '')) {
114
+ if (documented.has(this.getPageKey(nextPath)) || stateManager.hasVisitedState(nextPath)) continue;
115
+ this.enqueuePath(nextPath, queue, queued);
116
+ }
117
+ continue;
118
+ }
119
+ }
120
+
112
121
  const research = await this.explorBot.agentResearcher().research(state, {
113
122
  screenshot: this.shouldUseScreenshots(),
114
123
  force: true,
@@ -153,6 +162,9 @@ class DocBot {
153
162
  });
154
163
  documented.add(pageKey);
155
164
 
165
+ const templateRecord = buildTemplateRecord(state.url, state.ariaSnapshot ?? null);
166
+ if (templateRecord) templates.push(templateRecord);
167
+
156
168
  const nextPaths = this.extractNextPaths(state, baseUrl, research, documentation);
157
169
  const interactionPriorityPaths = new Set(this.extractInteractionPaths(baseUrl, documentation));
158
170
  for (const nextPath of nextPaths) {
@@ -209,6 +221,23 @@ class DocBot {
209
221
  return true;
210
222
  }
211
223
 
224
+ private shouldCollapseTemplates(override?: boolean): boolean {
225
+ if (override === false) {
226
+ return false;
227
+ }
228
+ if (this.config.docs?.collapseTemplatePages === false) {
229
+ return false;
230
+ }
231
+ return true;
232
+ }
233
+
234
+ private getTemplateSimilarity(override?: number): number | undefined {
235
+ const value = override ?? this.config.docs?.templateSimilarity;
236
+ if (value == null) return undefined;
237
+ if (!Number.isFinite(value) || value <= 0 || value > 100) return undefined;
238
+ return value;
239
+ }
240
+
212
241
  private extractNextPaths(state: WebPageState, baseUrl: string, research: string, documentation?: PageDocumentation): string[] {
213
242
  const paths: string[] = [];
214
243
  const seen = new Set<string>();
@@ -446,7 +475,8 @@ class DocBot {
446
475
  private async savePageDocumentation(state: WebPageState, documentation: PageDocumentation, research: string): Promise<string> {
447
476
  const pagePath = this.getPageFilePath(state.url);
448
477
  const screenshots = await this.captureScreenshots(state, research, pagePath);
449
- writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots), 'utf8');
478
+ const evidence = await this.captureEvidence(state, documentation, research, pagePath);
479
+ writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots, evidence), 'utf8');
450
480
  return pagePath;
451
481
  }
452
482
 
@@ -462,6 +492,19 @@ class DocBot {
462
492
  });
463
493
  }
464
494
 
495
+ private async captureEvidence(state: WebPageState, documentation: PageDocumentation, research: string, pagePath: string): Promise<Array<DocumentationScreenshot | null>> {
496
+ if (!this.shouldUseScreenshots()) {
497
+ return documentation.can.map(() => null);
498
+ }
499
+
500
+ return captureEvidenceScreenshots(this.explorBot.getExplorer(), state, documentation, {
501
+ pageFilePath: pagePath,
502
+ screenshotsDir: this.getScreenshotsDir(),
503
+ config: this.config,
504
+ research,
505
+ });
506
+ }
507
+
465
508
  private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): { indexPath: string; diagramPath: string } {
466
509
  const outputDir = this.configParser.getOutputDir();
467
510
  const indexPath = path.join(outputDir, 'index.md');
@@ -505,6 +548,8 @@ interface DocbotOptions extends ExplorBotOptions {
505
548
 
506
549
  interface CollectOptions {
507
550
  maxPages?: number;
551
+ collapseTemplatePages?: boolean;
552
+ templateSimilarity?: number;
508
553
  }
509
554
 
510
555
  interface CollectionResult {
@@ -7,7 +7,7 @@ import type { PageDocumentation, StateTransition } from './ai/documentarian.ts';
7
7
  import type { DocumentationScreenshot } from './screenshots.ts';
8
8
  import { type DocumentedPage, type SkippedPage, buildStateGraph, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph } from './state-diagram.ts';
9
9
 
10
- function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = []): string {
10
+ function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = [], evidence: Array<DocumentationScreenshot | null> = []): string {
11
11
  const lines: string[] = [];
12
12
  lines.push(`# ${state.url}`);
13
13
  lines.push('');
@@ -22,6 +22,42 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
22
22
  lines.push(ensureSentence(documentation.summary));
23
23
  lines.push('');
24
24
 
25
+ lines.push('## User Can');
26
+ lines.push('');
27
+
28
+ if (documentation.can.length === 0) {
29
+ lines.push('- No proven actions were identified from the collected research.');
30
+ lines.push('');
31
+ }
32
+
33
+ for (const [index, item] of documentation.can.entries()) {
34
+ lines.push(`- ${normalizeAction(item.action)} -> ${item.scope}`);
35
+ lines.push(` Proof: ${ensureSentence(item.evidence)}`);
36
+ const shot = evidence[index];
37
+ if (shot) {
38
+ lines.push('');
39
+ lines.push(` ![${normalizeInlineText(item.action)}](${shot.relativePath})`);
40
+ }
41
+ lines.push('');
42
+ }
43
+
44
+ lines.push('## User Might');
45
+ lines.push('');
46
+
47
+ if (documentation.might.length === 0) {
48
+ lines.push('- No assumption-based actions were identified.');
49
+ lines.push('');
50
+ }
51
+
52
+ for (const item of documentation.might) {
53
+ lines.push(`- ${normalizeAction(item.action, 'might')} -> ${item.scope}`);
54
+ lines.push(` Signal: ${ensureSentence(item.evidence)}`);
55
+ }
56
+
57
+ if (documentation.might.length > 0) {
58
+ lines.push('');
59
+ }
60
+
25
61
  if (screenshots.length > 0) {
26
62
  lines.push('## Screenshots');
27
63
  lines.push('');
@@ -34,6 +70,26 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
34
70
  }
35
71
  }
36
72
 
73
+ const navigationLinks = renderNavigationLinks(state);
74
+ if (navigationLinks.length > 0) {
75
+ lines.push('## Navigation');
76
+ lines.push('');
77
+ for (const link of navigationLinks) {
78
+ lines.push(`- ${link.title}: ${link.url}`);
79
+ }
80
+ lines.push('');
81
+ }
82
+
83
+ const qualityNotes = documentation.qualityNotes;
84
+ if (qualityNotes && qualityNotes.length > 0) {
85
+ lines.push('## Coverage Notes');
86
+ lines.push('');
87
+ for (const note of qualityNotes) {
88
+ lines.push(`- ${ensureSentence(note)}`);
89
+ }
90
+ lines.push('');
91
+ }
92
+
37
93
  const interactions = documentation.interactions;
38
94
  const pageStateDiagram = renderPageStateDiagram(state.title || state.url || 'page', state.url || '', interactions || []);
39
95
  if (pageStateDiagram) {
@@ -66,50 +122,6 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
66
122
  }
67
123
  }
68
124
 
69
- lines.push('## User Can');
70
- lines.push('');
71
-
72
- if (documentation.can.length === 0) {
73
- lines.push('- No proven actions were identified from the collected research.');
74
- lines.push('');
75
- }
76
-
77
- for (const item of documentation.can) {
78
- lines.push(`- ${normalizeAction(item.action)} -> ${item.scope}`);
79
- lines.push(` Proof: ${ensureSentence(item.evidence)}`);
80
- }
81
-
82
- if (documentation.can.length > 0) {
83
- lines.push('');
84
- }
85
-
86
- lines.push('## User Might');
87
- lines.push('');
88
-
89
- if (documentation.might.length === 0) {
90
- lines.push('- No assumption-based actions were identified.');
91
- lines.push('');
92
- }
93
-
94
- for (const item of documentation.might) {
95
- lines.push(`- ${normalizeAction(item.action, 'might')} -> ${item.scope}`);
96
- lines.push(` Signal: ${ensureSentence(item.evidence)}`);
97
- }
98
-
99
- if (documentation.might.length > 0) {
100
- lines.push('');
101
- }
102
-
103
- const qualityNotes = documentation.qualityNotes;
104
- if (qualityNotes && qualityNotes.length > 0) {
105
- lines.push('## Coverage Notes');
106
- lines.push('');
107
- for (const note of qualityNotes) {
108
- lines.push(`- ${ensureSentence(note)}`);
109
- }
110
- lines.push('');
111
- }
112
-
113
125
  return matter.stringify(`${lines.join('\n').trimEnd()}\n`, {
114
126
  url: state.url,
115
127
  format: APPLICATION_SPEC_FORMAT,
@@ -130,17 +142,7 @@ function renderSpecIndex(outputDir: string, startPath: string, pages: Documented
130
142
  lines.push('');
131
143
  const graph = buildStateGraph(outputDir, pages);
132
144
  const mermaid = renderMermaidFromGraph(graph, true);
133
- lines.push('## State Transitions');
134
- lines.push('');
135
- lines.push(`\`\`\`mermaid\n${mermaid}\n\`\`\``);
136
- lines.push('');
137
145
  const stateMap = renderStateMapFromGraph(graph);
138
- if (stateMap) {
139
- lines.push('### State Index');
140
- lines.push('');
141
- lines.push(stateMap);
142
- lines.push('');
143
- }
144
146
  lines.push('## Pages');
145
147
  lines.push('');
146
148
 
@@ -208,9 +210,31 @@ function renderSpecIndex(outputDir: string, startPath: string, pages: Documented
208
210
  lines.push('');
209
211
  }
210
212
 
213
+ lines.push('## State Transitions');
214
+ lines.push('');
215
+ lines.push(`\`\`\`mermaid\n${mermaid}\n\`\`\``);
216
+ lines.push('');
217
+ if (stateMap) {
218
+ lines.push('### State Index');
219
+ lines.push('');
220
+ lines.push(stateMap);
221
+ lines.push('');
222
+ }
223
+
211
224
  return `${lines.join('\n').trimEnd()}\n`;
212
225
  }
213
226
 
227
+ function renderNavigationLinks(state: WebPageState): Array<{ title: string; url: string }> {
228
+ const links: Array<{ title: string; url: string }> = [];
229
+ const seen = new Set<string>();
230
+ for (const link of state.links || []) {
231
+ if (!link.url || seen.has(link.url)) continue;
232
+ seen.add(link.url);
233
+ links.push({ title: normalizeInlineText(link.title) || link.url, url: link.url });
234
+ }
235
+ return links;
236
+ }
237
+
214
238
  function normalizeAction(action: string, kind: 'can' | 'might' = 'can'): string {
215
239
  const trimmed = normalizeInlineText(action);
216
240
  if (!trimmed) {
@@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
2
2
  import pixelmatch from 'pixelmatch';
3
3
  import type { Page } from 'playwright';
4
4
  import { PNG } from 'pngjs';
5
+ import { removeVisualAnnotations } from '../../../src/ai/researcher/coordinates.ts';
5
6
 
6
7
  const REGION_PADDING = 30;
7
8
  const SCREENSHOT_OPTIONS = { animations: 'disabled', caret: 'hide' } as const;
@@ -44,7 +45,7 @@ export function findChangedRegion(beforeScreenshot: Buffer, afterScreenshot: Buf
44
45
  return changedPixels ? addPadding(changedPixels, before.width, before.height, padding) : null;
45
46
  }
46
47
 
47
- function saveRegion(after: PNG, region: ScreenshotRegion, filePath: string): void {
48
+ export function saveRegion(after: PNG, region: ScreenshotRegion, filePath: string): void {
48
49
  const cropped = new PNG({ width: region.width, height: region.height });
49
50
  PNG.bitblt(after, cropped, region.x, region.y, region.width, region.height, 0, 0);
50
51
  writeFileSync(filePath, PNG.sync.write(cropped));
@@ -73,7 +74,23 @@ function findChangedPixelBounds(before: PNG, after: PNG): ScreenshotRegion | nul
73
74
  return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
74
75
  }
75
76
 
76
- function addPadding(region: ScreenshotRegion, imageWidth: number, imageHeight: number, padding = REGION_PADDING): ScreenshotRegion {
77
+ export function regionAround(png: PNG, box: { x: number; y: number; width: number; height: number }, viewport: { width: number; height: number } | null, padding: number): ScreenshotRegion {
78
+ let scaleX = 1;
79
+ let scaleY = 1;
80
+ if (viewport) {
81
+ scaleX = png.width / viewport.width;
82
+ scaleY = png.height / viewport.height;
83
+ }
84
+ const scaled: ScreenshotRegion = {
85
+ x: Math.round(box.x * scaleX),
86
+ y: Math.round(box.y * scaleY),
87
+ width: Math.round(box.width * scaleX),
88
+ height: Math.round(box.height * scaleY),
89
+ };
90
+ return addPadding(scaled, png.width, png.height, Math.round(padding * scaleX));
91
+ }
92
+
93
+ export function addPadding(region: ScreenshotRegion, imageWidth: number, imageHeight: number, padding = REGION_PADDING): ScreenshotRegion {
77
94
  const x = Math.max(0, region.x - padding);
78
95
  const y = Math.max(0, region.y - padding);
79
96
  const maxX = Math.min(imageWidth, region.x + region.width + padding);
@@ -81,14 +98,6 @@ function addPadding(region: ScreenshotRegion, imageWidth: number, imageHeight: n
81
98
  return { x, y, width: maxX - x, height: maxY - y };
82
99
  }
83
100
 
84
- async function removeVisualAnnotations(page: Page): Promise<void> {
85
- try {
86
- await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
87
- for (const element of elements) element.remove();
88
- });
89
- } catch {}
90
- }
91
-
92
101
  async function findOverlayRegion(page: Page, image: PNG, detectUnmarkedOverlay: boolean): Promise<ScreenshotRegion | null> {
93
102
  let box: { x: number; y: number; width: number; height: number } | null = null;
94
103
  try {