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
@@ -1,14 +1,19 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { PNG } from 'pngjs';
4
+ import { removeVisualAnnotations } from '../../../src/ai/researcher/coordinates.ts';
3
5
  import { parseResearchSections } from '../../../src/ai/researcher/parser.ts';
6
+ import { parseAriaLocator } from '../../../src/utils/aria.ts';
4
7
  import type Explorer from '../../../src/explorer.ts';
5
- import type { WebPageState } from '../../../src/state-manager.ts';
8
+ import { type WebPageState, normalizeUrl } from '../../../src/state-manager.ts';
6
9
  import { safeFilename, sanitizeFilename } from '../../../src/utils/strings.ts';
10
+ import type { PageDocumentation } from './ai/documentarian.ts';
7
11
  import type { DocStateTransition } from './ai/tools.ts';
8
12
  import type { DocbotConfig } from './config.ts';
9
- import { captureInteractionAfter, captureInteractionBefore } from './interaction-screenshots.ts';
13
+ import { addPadding, captureInteractionAfter, captureInteractionBefore, regionAround, saveRegion } from './interaction-screenshots.ts';
10
14
 
11
15
  const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
16
+ const EVIDENCE_PADDING = 250;
12
17
 
13
18
  export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
14
19
  const page = explorer.page;
@@ -16,6 +21,8 @@ export async function captureDocumentationScreenshots(explorer: Explorer, state:
16
21
  return [];
17
22
  }
18
23
 
24
+ await dismissTransientOverlay(page);
25
+ await removeVisualAnnotations(page);
19
26
  mkdirSync(options.screenshotsDir, { recursive: true });
20
27
 
21
28
  const screenshots: DocumentationScreenshot[] = [];
@@ -37,6 +44,101 @@ export async function captureDocumentationScreenshots(explorer: Explorer, state:
37
44
  return screenshots;
38
45
  }
39
46
 
47
+ export async function captureEvidenceScreenshots(explorer: Explorer, state: WebPageState, documentation: PageDocumentation, options: DocumentationScreenshotOptions): Promise<Array<DocumentationScreenshot | null>> {
48
+ const page = explorer.page;
49
+ if (!page) {
50
+ return documentation.can.map(() => null);
51
+ }
52
+
53
+ await dismissTransientOverlay(page);
54
+ await removeVisualAnnotations(page);
55
+ if (!isOnDocumentedPage(page, state)) {
56
+ return documentation.can.map(() => null);
57
+ }
58
+ mkdirSync(options.screenshotsDir, { recursive: true });
59
+ const pageName = sanitizeFilename(state.url || 'page') || 'page';
60
+ const shots: Array<DocumentationScreenshot | null> = [];
61
+ for (const [index, item] of documentation.can.entries()) {
62
+ shots.push(await captureEvidenceScreenshot(page, pageName, index, item, options));
63
+ }
64
+ return shots;
65
+ }
66
+
67
+ async function captureEvidenceScreenshot(page: any, pageName: string, index: number, item: { action: string; element?: string | null }, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
68
+ if (!item.element) return null;
69
+ const element = resolveResearchLocator(item.element, options.research);
70
+ if (!element) return null;
71
+ const locatorInfo = parseAriaLocator(element);
72
+ try {
73
+ const locator = resolveEvidenceLocator(page, element, locatorInfo);
74
+ if ((await locator.count()) !== 1) return null;
75
+ await locator.scrollIntoViewIfNeeded({ timeout: 2000 });
76
+ const box = await locator.boundingBox();
77
+ if (!box || box.width <= 0 || box.height <= 0) return null;
78
+ const image = await page.screenshot();
79
+ const png = PNG.sync.read(image);
80
+ const region = regionAround(png, box, await page.viewportSize(), EVIDENCE_PADDING);
81
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_can_${index + 1}`, '.png'));
82
+ saveRegion(png, region, filePath);
83
+ return {
84
+ title: item.action,
85
+ path: filePath,
86
+ relativePath: toMarkdownPath(options.pageFilePath, filePath),
87
+ kind: 'evidence',
88
+ };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function resolveResearchLocator(requested: string, research?: string): string | null {
95
+ if (!research) return requested;
96
+ const requestedAria = parseAriaLocator(requested);
97
+ const requestedCss = unwrapLocator(requested);
98
+ for (const section of parseResearchSections(research)) {
99
+ for (const element of section.elements) {
100
+ if (requestedAria && element.aria?.role === requestedAria.role && element.aria.text === requestedAria.text) {
101
+ return `{ role: '${element.aria.role}', text: '${element.aria.text}' }`;
102
+ }
103
+ if (element.css && requestedCss === unwrapLocator(element.css)) return element.css;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function unwrapLocator(locator: string): string {
110
+ const trimmed = locator.trim();
111
+ const first = trimmed.at(0);
112
+ const last = trimmed.at(-1);
113
+ if (first === last && (first === '`' || first === '"' || first === "'")) {
114
+ return trimmed.slice(1, -1).trim();
115
+ }
116
+ return trimmed;
117
+ }
118
+
119
+ function isOnDocumentedPage(page: any, state: WebPageState): boolean {
120
+ try {
121
+ const current = new URL(page.url());
122
+ const expected = new URL(state.url || '', current.origin);
123
+ return current.origin === expected.origin && normalizeUrl(current.href) === normalizeUrl(expected.href);
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function resolveEvidenceLocator(page: any, element: string, locatorInfo: { role: string; text: string } | null): any {
130
+ if (locatorInfo?.text) {
131
+ return page.getByRole(locatorInfo.role, { name: locatorInfo.text, exact: true });
132
+ }
133
+ return page.locator(element);
134
+ }
135
+
136
+ async function dismissTransientOverlay(page: any): Promise<void> {
137
+ try {
138
+ await page.keyboard.press('Escape');
139
+ } catch {}
140
+ }
141
+
40
142
  export function getScreenshotSections(research: string): ScreenshotSection[] {
41
143
  const sections: ScreenshotSection[] = [];
42
144
  const seen = new Set<string>();
@@ -150,7 +252,7 @@ export interface DocumentationScreenshot {
150
252
  title: string;
151
253
  path: string;
152
254
  relativePath: string;
153
- kind: 'page' | 'section' | 'state';
255
+ kind: 'page' | 'section' | 'state' | 'evidence';
154
256
  selector?: string;
155
257
  }
156
258
 
@@ -158,6 +260,7 @@ interface DocumentationScreenshotOptions {
158
260
  pageFilePath: string;
159
261
  screenshotsDir: string;
160
262
  config: DocbotConfig;
263
+ research?: string;
161
264
  }
162
265
 
163
266
  interface ScreenshotSection {
@@ -0,0 +1,31 @@
1
+ import { ariaTemplateSignature } from '../../../src/utils/aria.ts';
2
+ import { diceSimilarity } from '../../../src/utils/similarity.ts';
3
+
4
+ const TEMPLATE_MIN_SIGNATURE_SIZE = 8;
5
+ const TEMPLATE_SIMILARITY_THRESHOLD = 90;
6
+
7
+ export function buildTemplateRecord(url: string, snapshot: string | null): TemplateRecord | null {
8
+ const signature = ariaTemplateSignature(snapshot);
9
+ if (signature.size < TEMPLATE_MIN_SIGNATURE_SIZE) return null;
10
+ return { url, signature };
11
+ }
12
+
13
+ export function findTemplateMatch(snapshot: string | null, known: TemplateRecord[], threshold: number = TEMPLATE_SIMILARITY_THRESHOLD): string | null {
14
+ const signature = ariaTemplateSignature(snapshot);
15
+ if (signature.size < TEMPLATE_MIN_SIGNATURE_SIZE) return null;
16
+ let bestUrl: string | null = null;
17
+ let bestScore = 0;
18
+ for (const record of known) {
19
+ const score = diceSimilarity(signature, record.signature);
20
+ if (score < bestScore) continue;
21
+ bestScore = score;
22
+ bestUrl = record.url;
23
+ }
24
+ if (bestScore < threshold) return null;
25
+ return bestUrl;
26
+ }
27
+
28
+ export interface TemplateRecord {
29
+ url: string;
30
+ signature: Set<string>;
31
+ }
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import dedent from 'dedent';
3
3
  import { keepServerRunning } from '../../../src/browser-server.ts';
4
+ import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
4
5
  import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
5
6
  import { isVerboseMode, setQuietMode } from '../../../src/utils/logger.ts';
6
7
  import { clearActivityLine, trackActivityLine } from './activity-line.ts';
@@ -220,6 +221,8 @@ export function createPrimaCommands(name = 'prima'): Command {
220
221
  process.exit(0);
221
222
  });
222
223
 
224
+ RecommendedModelsCommand.register(cmd);
225
+
223
226
  addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
224
227
  .addHelpText('after', `\n${statusHelp}`)
225
228
  .action(async (hash, options) => {
@@ -15,7 +15,7 @@ import { getAliveEndpoint, launchServer, listInstances, stopServer } from '../..
15
15
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
16
16
  import { ConfigMissingError, ConfigParser, EXPLORBOT_ENV_VARS, type ExplorbotConfig, outputPath } from '../../../src/config.ts';
17
17
  import { ExplorBot } from '../../../src/explorbot.ts';
18
- import { listSites } from '../../../src/global-config.ts';
18
+ import { findSiteWith, listSites } from '../../../src/global-config.ts';
19
19
  import { Reporter } from '../../../src/reporter.ts';
20
20
  import type { WebPageState } from '../../../src/state-manager.ts';
21
21
  import { Stats } from '../../../src/stats.ts';
@@ -1067,8 +1067,7 @@ export class Prima {
1067
1067
 
1068
1068
  async status(hash: string): Promise<EnvelopeData> {
1069
1069
  if (!this.artifactsDir) {
1070
- const sites = listSites();
1071
- const site = sites.find((candidate) => existsSync(path.join(candidate.dir, 'output', 'prima', hash))) || sites[0];
1070
+ const site = findSiteWith(path.join('output', 'prima', hash)) || listSites()[0];
1072
1071
  if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
1073
1072
  await this.loadConfig();
1074
1073
  }
@@ -1113,7 +1112,7 @@ export class Prima {
1113
1112
  aria: result.ariaSnapshot,
1114
1113
  html: await result.combinedHtml(),
1115
1114
  screenshot: result.screenshot,
1116
- requests: this.bot.requestStore().getRequests(),
1115
+ requests: this.bot.requestStore().getMadeRequests(),
1117
1116
  });
1118
1117
  }
1119
1118
 
@@ -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';
@@ -296,10 +297,14 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
296
297
  planfileArg = options.fromPlan;
297
298
  indexArg = planfile;
298
299
  }
299
- const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
300
- const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
300
+ const peeked = Plan.loadFromFile(planfileArg);
301
+ const explorBot = new ExplorBot(buildExplorBotOptions(peeked?.startUrl, options));
301
302
  await explorBot.start();
302
- const plan = explorBot.loadPlan(planfileArg);
303
+ let plan = peeked;
304
+ if (plan)
305
+ explorBot.setCurrentPlan(plan);
306
+ if (!plan)
307
+ plan = explorBot.loadPlan(planfileArg);
303
308
  const pending = plan.getPendingTests();
304
309
  log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
305
310
  const startUrl = plan.startUrl;
@@ -420,6 +425,7 @@ program
420
425
  process.exit(1);
421
426
  }
422
427
  });
428
+ RecommendedModelsCommand.register(program);
423
429
  program
424
430
  .command('init')
425
431
  .description('Initialize configuration for a project or for this machine')
@@ -61,7 +61,7 @@ export function createCurlerTools(apiClient, requestState, test, searchSpec) {
61
61
  body: input.body,
62
62
  queryParams: input.queryParams,
63
63
  });
64
- requestState.addRequest(result);
64
+ requestState.addMadeRequest(result);
65
65
  if (result.error) {
66
66
  tag('error').log(`${input.method} ${input.path} > Network error: ${result.error}`);
67
67
  activeNote.commit(TestResult.FAILED);
@@ -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.js";
5
+ import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
5
6
  import { listSites } from "../../../src/global-config.js";
6
7
  import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
7
8
  import { getStyles } from "./ai/chief/styles.js";
@@ -95,6 +96,7 @@ export function createApiCommands(name = 'api') {
95
96
  process.exit(1);
96
97
  }
97
98
  });
99
+ RecommendedModelsCommand.register(cmd);
98
100
  addCommonOptions(cmd.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1-3, *')).action(async (planfile, index, options) => {
99
101
  setPreserveConsoleLogs(true);
100
102
  try {
@@ -1,6 +1,8 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
+ import { parseAriaLocator } from "../../../../src/utils/aria.js";
3
4
  import { tag } from "../../../../src/utils/logger.js";
5
+ import { parseResearchSections } from "../../../../src/ai/researcher/parser.js";
4
6
  import { collectDocInteractions } from "./tools.js";
5
7
  class Documentarian {
6
8
  provider;
@@ -147,6 +149,9 @@ class Documentarian {
147
149
  Only list capabilities that are grounded in the provided page research.
148
150
  Put actions into "can" only when there is direct evidence in the page context.
149
151
  Put actions into "might" only when the UI strongly suggests a capability but proof is incomplete.
152
+ 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.
153
+ Raw interaction observations that change the URL are navigation evidence only. Never turn them into "can" or "might" actions.
154
+ 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.
150
155
  Describe each action from the end-user perspective.
151
156
  Be explicit about scope:
152
157
  - one item
@@ -154,7 +159,7 @@ class Documentarian {
154
159
  - bulk operations
155
160
  - all items
156
161
  - page-level
157
- Avoid implementation details, selectors, and QA wording.
162
+ Avoid implementation details, selectors, and QA wording — except the machine-consumed element field, which is a locator, not prose.
158
163
  Avoid duplicate actions with different phrasing.
159
164
  </rules>
160
165
 
@@ -203,6 +208,8 @@ class Documentarian {
203
208
  - action: concise user-facing capability phrased as "user can ..."
204
209
  - scope: one of one item, list of items, bulk operations, all items, page-level
205
210
  - evidence: short reason based on visible UI or research
211
+ - 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.
212
+ Never copy the human-readable Element description from interaction_observations into element. It is not a locator.
206
213
  </output_requirements>
207
214
  `;
208
215
  }
@@ -233,8 +240,37 @@ class Documentarian {
233
240
  const message = error instanceof Error ? error.message : String(error);
234
241
  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');
235
242
  }
236
- normalizeDocumentation(documentation, _state, _research) {
243
+ normalizeDocumentation(documentation, _state, research) {
237
244
  const normalized = { ...documentation };
245
+ const navigationLocators = new Set();
246
+ const researchLocators = new Set();
247
+ for (const section of parseResearchSections(research)) {
248
+ for (const element of section.elements) {
249
+ const aria = element.aria ? `{ role: '${element.aria.role}', text: '${element.aria.text}' }` : null;
250
+ if (aria)
251
+ researchLocators.add(this.locatorKey(aria));
252
+ if (element.css)
253
+ researchLocators.add(this.locatorKey(element.css));
254
+ if (element.aria?.role !== 'link')
255
+ continue;
256
+ if (aria)
257
+ navigationLocators.add(this.locatorKey(aria));
258
+ if (element.css)
259
+ navigationLocators.add(this.locatorKey(element.css));
260
+ }
261
+ }
262
+ normalized.can = normalized.can.filter((capability) => {
263
+ if (!capability.element)
264
+ return true;
265
+ const key = this.locatorKey(capability.element);
266
+ return researchLocators.has(key) && !navigationLocators.has(key);
267
+ });
268
+ normalized.might = normalized.might.filter((capability) => {
269
+ if (!capability.element)
270
+ return false;
271
+ const key = this.locatorKey(capability.element);
272
+ return researchLocators.has(key) && !navigationLocators.has(key);
273
+ });
238
274
  if (!normalized.interactions) {
239
275
  normalized.interactions = undefined;
240
276
  }
@@ -244,6 +280,18 @@ class Documentarian {
244
280
  qualityNotes,
245
281
  });
246
282
  }
283
+ locatorKey(locator) {
284
+ const aria = parseAriaLocator(locator);
285
+ if (aria)
286
+ return `aria:${aria.role}:${aria.text}`;
287
+ const trimmed = locator.trim();
288
+ const first = trimmed.at(0);
289
+ const last = trimmed.at(-1);
290
+ if (first === last && (first === '`' || first === '"' || first === "'")) {
291
+ return `css:${trimmed.slice(1, -1).trim()}`;
292
+ }
293
+ return `css:${trimmed}`;
294
+ }
247
295
  evaluateDocumentationQuality(documentation) {
248
296
  const notes = [];
249
297
  if ((documentation.interactions || []).length === 0 && this.config.docs?.interactive) {
@@ -300,6 +348,13 @@ const capabilitySchema = z.object({
300
348
  action: z.string(),
301
349
  scope: z.enum(['one item', 'list of items', 'bulk operations', 'all items', 'page-level']),
302
350
  evidence: z.string(),
351
+ element: z.string().nullable().optional(),
352
+ });
353
+ const generatedCapabilitySchema = z.object({
354
+ action: z.string(),
355
+ scope: z.enum(['one item', 'list of items', 'bulk operations', 'all items', 'page-level']),
356
+ evidence: z.string(),
357
+ element: z.string().nullable(),
303
358
  });
304
359
  const stateTransitionSchema = z.object({
305
360
  action: z.string(),
@@ -339,11 +394,14 @@ const stateTransitionSchema = z.object({
339
394
  .optional(),
340
395
  });
341
396
  const generatedPageDocumentationSchema = z.object({
397
+ summary: z.string(),
398
+ can: z.array(generatedCapabilitySchema),
399
+ might: z.array(generatedCapabilitySchema),
400
+ });
401
+ const pageDocumentationSchema = z.object({
342
402
  summary: z.string(),
343
403
  can: z.array(capabilitySchema),
344
404
  might: z.array(capabilitySchema),
345
- });
346
- const pageDocumentationSchema = generatedPageDocumentationSchema.extend({
347
405
  interactions: z.array(stateTransitionSchema).optional(),
348
406
  qualityNotes: z.array(z.string()).optional(),
349
407
  });
@@ -132,7 +132,7 @@ function buildTransition(candidate, beforeState, afterState, changes) {
132
132
  function describeTargetState(beforeState, afterState, candidate) {
133
133
  const beforeFocus = detectFocusArea(beforeState.ariaSnapshot || null);
134
134
  const afterFocus = detectFocusArea(afterState.ariaSnapshot || null);
135
- if (afterFocus.detected && (!beforeFocus.detected || beforeFocus.name !== afterFocus.name)) {
135
+ if (afterFocus.type && (!beforeFocus.type || beforeFocus.name !== afterFocus.name)) {
136
136
  return {
137
137
  kind: afterFocus.type || 'dialog',
138
138
  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.js";
5
+ import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
5
6
  import { remote } from "../../../src/remote.js";
6
7
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from "../../../src/utils/logger.js";
7
8
  import { DocBot } from "./docbot.js";
@@ -34,7 +35,12 @@ function addCommonOptions(cmd) {
34
35
  export function createDocsCommands(name = 'docs') {
35
36
  const cmd = new Command(name);
36
37
  cmd.description('AI-powered website documentation collector');
37
- 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) => {
38
+ addCommonOptions(cmd
39
+ .command('collect <path>')
40
+ .description('Crawl pages and generate documentation spec')
41
+ .option('--max-pages <count>', 'Maximum number of pages to document')
42
+ .option('--no-collapse-template-pages', 'Visit every page even when its layout matches a documented page')
43
+ .option('--template-similarity <percent>', 'Structural similarity percent that counts pages as the same layout (default 90)')).action(async (startPath, options) => {
38
44
  setPreserveConsoleLogs(true);
39
45
  try {
40
46
  const bot = new DocBot({
@@ -46,7 +52,11 @@ export function createDocsCommands(name = 'docs') {
46
52
  if (options.maxPages) {
47
53
  maxPages = Number.parseInt(options.maxPages, 10);
48
54
  }
49
- const result = await bot.collect(startPath, { maxPages });
55
+ let templateSimilarity;
56
+ if (options.templateSimilarity) {
57
+ templateSimilarity = Number.parseInt(options.templateSimilarity, 10);
58
+ }
59
+ const result = await bot.collect(startPath, { maxPages, collapseTemplatePages: options.collapseTemplatePages, templateSimilarity });
50
60
  console.log(`\nDocumented ${result.pages.length} page(s)`);
51
61
  console.log(`Skipped ${result.skipped.length} page(s)`);
52
62
  console.log(`Spec index: ${result.indexPath}`);
@@ -74,6 +84,7 @@ export function createDocsCommands(name = 'docs') {
74
84
  process.exit(1);
75
85
  }
76
86
  });
87
+ RecommendedModelsCommand.register(cmd);
77
88
  cmd
78
89
  .command('init')
79
90
  .description('Initialize doc collector configuration')
@@ -101,6 +112,8 @@ export function createDocsCommands(name = 'docs') {
101
112
  interactive: false,
102
113
  ignoreErrors: true,
103
114
  collapseDynamicPages: true,
115
+ collapseTemplatePages: true,
116
+ templateSimilarity: 90,
104
117
  scope: 'site',
105
118
  includePaths: [],
106
119
  excludePaths: [],
@@ -109,6 +109,7 @@ class DocbotConfigParser {
109
109
  interactive: false,
110
110
  ignoreErrors: true,
111
111
  collapseDynamicPages: true,
112
+ collapseTemplatePages: true,
112
113
  scope: 'site',
113
114
  includePaths: [],
114
115
  excludePaths: [],
@@ -9,8 +9,9 @@ import { DocbotConfigParser } from "./config.js";
9
9
  import { renderPageDocumentation, renderSpecIndex } from "./docs-renderer.js";
10
10
  import { getDocPageKey, shouldCrawlDocPath } from "./path-filter.js";
11
11
  import { extractResearchNavigationTargets } from "./research-navigation.js";
12
- import { captureBeforeInteraction, captureDocumentationScreenshots, captureInteractionScreenshot } from "./screenshots.js";
12
+ import { captureBeforeInteraction, captureDocumentationScreenshots, captureEvidenceScreenshots, captureInteractionScreenshot } from "./screenshots.js";
13
13
  import { renderMermaidBody } from "./state-diagram.js";
14
+ import { buildTemplateRecord, findTemplateMatch } from "./template-dedup.js";
14
15
  class DocBot {
15
16
  explorBot;
16
17
  configParser;
@@ -53,6 +54,7 @@ class DocBot {
53
54
  const queue = [];
54
55
  const queued = new Set();
55
56
  const documented = new Set();
57
+ const templates = [];
56
58
  const pages = [];
57
59
  const skipped = [];
58
60
  const baseUrl = this.explorBot.getConfig().playwright.url;
@@ -73,14 +75,6 @@ class DocBot {
73
75
  try {
74
76
  tag('info').log(`Collecting docs for ${this.toDisplayUrl(target, baseUrl)}`);
75
77
  await this.explorBot.visit(target);
76
- if (stateManager.isInDeadLoop()) {
77
- tag('warning').log('Dead loop detected during docs crawl, stopping collection');
78
- skipped.push({
79
- url: target,
80
- reason: 'dead loop detected during crawl',
81
- });
82
- break;
83
- }
84
78
  const state = this.explorBot.getCurrentState();
85
79
  if (!state) {
86
80
  skipped.push({
@@ -93,6 +87,22 @@ class DocBot {
93
87
  if (documented.has(pageKey)) {
94
88
  continue;
95
89
  }
90
+ if (this.shouldCollapseTemplates(opts.collapseTemplatePages)) {
91
+ const templateUrl = findTemplateMatch(state.ariaSnapshot ?? null, templates, this.getTemplateSimilarity(opts.templateSimilarity));
92
+ if (templateUrl) {
93
+ skipped.push({
94
+ url: state.url,
95
+ reason: `same layout as ${templateUrl} (only content differs)`,
96
+ });
97
+ documented.add(pageKey);
98
+ for (const nextPath of this.extractNextPaths(state, baseUrl, '')) {
99
+ if (documented.has(this.getPageKey(nextPath)) || stateManager.hasVisitedState(nextPath))
100
+ continue;
101
+ this.enqueuePath(nextPath, queue, queued);
102
+ }
103
+ continue;
104
+ }
105
+ }
96
106
  const research = await this.explorBot.agentResearcher().research(state, {
97
107
  screenshot: this.shouldUseScreenshots(),
98
108
  force: true,
@@ -134,6 +144,9 @@ class DocBot {
134
144
  filePath,
135
145
  });
136
146
  documented.add(pageKey);
147
+ const templateRecord = buildTemplateRecord(state.url, state.ariaSnapshot ?? null);
148
+ if (templateRecord)
149
+ templates.push(templateRecord);
137
150
  const nextPaths = this.extractNextPaths(state, baseUrl, research, documentation);
138
151
  const interactionPriorityPaths = new Set(this.extractInteractionPaths(baseUrl, documentation));
139
152
  for (const nextPath of nextPaths) {
@@ -184,6 +197,23 @@ class DocBot {
184
197
  }
185
198
  return true;
186
199
  }
200
+ shouldCollapseTemplates(override) {
201
+ if (override === false) {
202
+ return false;
203
+ }
204
+ if (this.config.docs?.collapseTemplatePages === false) {
205
+ return false;
206
+ }
207
+ return true;
208
+ }
209
+ getTemplateSimilarity(override) {
210
+ const value = override ?? this.config.docs?.templateSimilarity;
211
+ if (value == null)
212
+ return undefined;
213
+ if (!Number.isFinite(value) || value <= 0 || value > 100)
214
+ return undefined;
215
+ return value;
216
+ }
187
217
  extractNextPaths(state, baseUrl, research, documentation) {
188
218
  const paths = [];
189
219
  const seen = new Set();
@@ -393,7 +423,8 @@ class DocBot {
393
423
  async savePageDocumentation(state, documentation, research) {
394
424
  const pagePath = this.getPageFilePath(state.url);
395
425
  const screenshots = await this.captureScreenshots(state, research, pagePath);
396
- writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots), 'utf8');
426
+ const evidence = await this.captureEvidence(state, documentation, research, pagePath);
427
+ writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots, evidence), 'utf8');
397
428
  return pagePath;
398
429
  }
399
430
  async captureScreenshots(state, research, pagePath) {
@@ -406,6 +437,17 @@ class DocBot {
406
437
  config: this.config,
407
438
  });
408
439
  }
440
+ async captureEvidence(state, documentation, research, pagePath) {
441
+ if (!this.shouldUseScreenshots()) {
442
+ return documentation.can.map(() => null);
443
+ }
444
+ return captureEvidenceScreenshots(this.explorBot.getExplorer(), state, documentation, {
445
+ pageFilePath: pagePath,
446
+ screenshotsDir: this.getScreenshotsDir(),
447
+ config: this.config,
448
+ research,
449
+ });
450
+ }
409
451
  saveIndex(startPath, pages, skipped, maxPages) {
410
452
  const outputDir = this.configParser.getOutputDir();
411
453
  const indexPath = path.join(outputDir, 'index.md');