explorbot 0.4.0 → 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 (177) hide show
  1. package/bin/explorbot-cli.ts +13 -6
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  4. package/boat/api-tester/src/ai/curler.ts +7 -1
  5. package/boat/api-tester/src/apibot.ts +10 -4
  6. package/boat/api-tester/src/cli.ts +15 -2
  7. package/boat/api-tester/src/config.ts +28 -8
  8. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  9. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  10. package/boat/doc-collector/src/ai/tools.ts +1 -1
  11. package/boat/doc-collector/src/cli.ts +21 -3
  12. package/boat/doc-collector/src/config.ts +3 -0
  13. package/boat/doc-collector/src/docbot.ts +57 -12
  14. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  15. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  16. package/boat/doc-collector/src/screenshots.ts +106 -3
  17. package/boat/doc-collector/src/template-dedup.ts +31 -0
  18. package/boat/prima/bin/prima-cli.ts +2 -0
  19. package/boat/prima/src/cli.ts +5 -0
  20. package/boat/prima/src/prima.ts +3 -4
  21. package/dist/bin/explorbot-cli.js +14 -6
  22. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  23. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  24. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  25. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  26. package/dist/boat/api-tester/src/apibot.js +7 -3
  27. package/dist/boat/api-tester/src/cli.js +14 -2
  28. package/dist/boat/api-tester/src/config.js +31 -8
  29. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  30. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  31. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  32. package/dist/boat/doc-collector/src/cli.js +18 -3
  33. package/dist/boat/doc-collector/src/config.js +1 -0
  34. package/dist/boat/doc-collector/src/docbot.js +53 -11
  35. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  36. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  37. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  38. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  39. package/dist/boat/prima/bin/prima-cli.js +2 -0
  40. package/dist/boat/prima/src/cli.js +5 -0
  41. package/dist/boat/prima/src/prima.js +3 -4
  42. package/dist/package.json +1 -1
  43. package/dist/rules/planner/styles/normal.md +1 -1
  44. package/dist/src/action-result.d.ts +2 -2
  45. package/dist/src/action-result.js +15 -17
  46. package/dist/src/action.d.ts +1 -1
  47. package/dist/src/action.js +15 -15
  48. package/dist/src/ai/captain.js +1 -1
  49. package/dist/src/ai/fisherman/request-haul.d.ts +11 -0
  50. package/dist/src/ai/fisherman/request-haul.js +28 -0
  51. package/dist/src/ai/fisherman-tools.d.ts +10 -1
  52. package/dist/src/ai/fisherman-tools.js +35 -31
  53. package/dist/src/ai/fisherman.d.ts +3 -2
  54. package/dist/src/ai/fisherman.js +8 -6
  55. package/dist/src/ai/navigator.js +1 -1
  56. package/dist/src/ai/pilot.js +21 -16
  57. package/dist/src/ai/planner.js +9 -7
  58. package/dist/src/ai/provider.js +8 -14
  59. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  60. package/dist/src/ai/researcher/coordinates.js +13 -1
  61. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  62. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  63. package/dist/src/ai/researcher.js +3 -1
  64. package/dist/src/ai/rules.js +11 -3
  65. package/dist/src/ai/tester.js +20 -20
  66. package/dist/src/ai/tools.d.ts +1 -1
  67. package/dist/src/ai/tools.js +18 -74
  68. package/dist/src/api/request-store.d.ts +1 -5
  69. package/dist/src/api/request-store.js +12 -22
  70. package/dist/src/api/spec-reader.js +1 -1
  71. package/dist/src/commands/config-command.js +3 -10
  72. package/dist/src/commands/drill-command.js +1 -1
  73. package/dist/src/commands/explore-command.js +12 -1
  74. package/dist/src/commands/index.js +2 -0
  75. package/dist/src/commands/init-command.js +3 -7
  76. package/dist/src/commands/options/base-option.d.ts +8 -0
  77. package/dist/src/commands/options/base-option.js +12 -0
  78. package/dist/src/commands/options/index.d.ts +5 -0
  79. package/dist/src/commands/options/index.js +5 -0
  80. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  81. package/dist/src/commands/options/knowledge-option.js +12 -0
  82. package/dist/src/commands/options/ws-option.d.ts +7 -0
  83. package/dist/src/commands/options/ws-option.js +21 -0
  84. package/dist/src/commands/plans-command.d.ts +4 -1
  85. package/dist/src/commands/plans-command.js +4 -4
  86. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  87. package/dist/src/commands/recommended-models-command.js +85 -0
  88. package/dist/src/config.d.ts +3 -0
  89. package/dist/src/config.js +18 -2
  90. package/dist/src/experience-tracker.js +1 -1
  91. package/dist/src/explorbot.js +4 -4
  92. package/dist/src/global-config.d.ts +2 -0
  93. package/dist/src/global-config.js +6 -0
  94. package/dist/src/knowledge-tracker.d.ts +20 -7
  95. package/dist/src/knowledge-tracker.js +69 -31
  96. package/dist/src/remote.d.ts +0 -3
  97. package/dist/src/remote.js +0 -18
  98. package/dist/src/state-manager.d.ts +3 -3
  99. package/dist/src/state-manager.js +8 -8
  100. package/dist/src/test-plan.d.ts +1 -0
  101. package/dist/src/test-plan.js +13 -9
  102. package/dist/src/utils/aria.d.ts +1 -1
  103. package/dist/src/utils/aria.js +23 -2
  104. package/dist/src/utils/cli-section.d.ts +1 -0
  105. package/dist/src/utils/cli-section.js +12 -0
  106. package/dist/src/utils/html-diff.d.ts +1 -0
  107. package/dist/src/utils/html-diff.js +5 -5
  108. package/dist/src/utils/html.d.ts +2 -0
  109. package/dist/src/utils/html.js +5 -4
  110. package/dist/src/utils/overlay.d.ts +19 -55
  111. package/dist/src/utils/overlay.js +94 -148
  112. package/dist/src/utils/region.d.ts +41 -0
  113. package/dist/src/utils/region.js +99 -0
  114. package/dist/src/utils/similarity.d.ts +1 -0
  115. package/dist/src/utils/similarity.js +11 -0
  116. package/dist/src/utils/url-matcher.d.ts +1 -1
  117. package/dist/src/utils/url-matcher.js +4 -4
  118. package/docs/api-testing/basics.md +15 -0
  119. package/docs/api-testing/planning.md +10 -1
  120. package/docs/doc-collection/basics.md +11 -5
  121. package/docs/doc-collection/crawling.md +21 -1
  122. package/docs/reference/commands.md +27 -4
  123. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  124. package/docs/workflow/agentic-usage.md +11 -2
  125. package/docs/workflow/application-spec.md +4 -0
  126. package/docs/workflow/knowledge.md +46 -2
  127. package/package.json +1 -1
  128. package/rules/planner/styles/normal.md +1 -1
  129. package/src/action-result.ts +15 -16
  130. package/src/action.ts +16 -15
  131. package/src/ai/captain.ts +1 -1
  132. package/src/ai/fisherman/request-haul.ts +32 -0
  133. package/src/ai/fisherman-tools.ts +38 -32
  134. package/src/ai/fisherman.ts +8 -7
  135. package/src/ai/navigator.ts +1 -1
  136. package/src/ai/pilot.ts +19 -14
  137. package/src/ai/planner.ts +9 -8
  138. package/src/ai/provider.ts +8 -12
  139. package/src/ai/researcher/coordinates.ts +13 -1
  140. package/src/ai/researcher/deep-analysis.ts +9 -9
  141. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  142. package/src/ai/researcher.ts +2 -1
  143. package/src/ai/rules.ts +11 -3
  144. package/src/ai/tester.ts +18 -18
  145. package/src/ai/tools.ts +18 -86
  146. package/src/api/request-store.ts +13 -27
  147. package/src/api/spec-reader.ts +1 -1
  148. package/src/commands/config-command.ts +3 -8
  149. package/src/commands/drill-command.ts +1 -1
  150. package/src/commands/explore-command.ts +12 -1
  151. package/src/commands/index.ts +2 -0
  152. package/src/commands/init-command.ts +4 -11
  153. package/src/commands/options/base-option.ts +18 -0
  154. package/src/commands/options/index.ts +7 -0
  155. package/src/commands/options/knowledge-option.ts +14 -0
  156. package/src/commands/options/ws-option.ts +24 -0
  157. package/src/commands/plans-command.ts +5 -5
  158. package/src/commands/recommended-models-command.ts +92 -0
  159. package/src/config.ts +20 -2
  160. package/src/experience-tracker.ts +1 -1
  161. package/src/explorbot.ts +4 -4
  162. package/src/global-config.ts +8 -0
  163. package/src/knowledge-tracker.ts +94 -36
  164. package/src/remote.ts +0 -20
  165. package/src/state-manager.ts +10 -9
  166. package/src/test-plan.ts +12 -10
  167. package/src/utils/aria.ts +26 -3
  168. package/src/utils/cli-section.ts +12 -0
  169. package/src/utils/html-diff.ts +6 -6
  170. package/src/utils/html.ts +6 -4
  171. package/src/utils/overlay.ts +105 -192
  172. package/src/utils/region.ts +132 -0
  173. package/src/utils/similarity.ts +9 -0
  174. package/src/utils/url-matcher.ts +3 -3
  175. package/dist/src/utils/request-map.d.ts +0 -7
  176. package/dist/src/utils/request-map.js +0 -16
  177. package/src/utils/request-map.ts +0 -19
@@ -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;
@@ -24,7 +25,7 @@ class DocBot {
24
25
 
25
26
  constructor(options: DocbotOptions = {}) {
26
27
  this.options = options;
27
- const baseUrl = this.extractAbsoluteBaseUrl(options.startUrl || '/');
28
+ const baseUrl = this.extractAbsoluteBaseUrl(options.startUrl || '/') || options.baseUrl;
28
29
  this.explorBot = new ExplorBot({
29
30
  baseUrl,
30
31
  verbose: options.verbose,
@@ -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 {
@@ -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,5 +1,7 @@
1
1
  #!/usr/bin/env bun
2
+ import { knowledgeOption } from '../../../src/commands/options/index.ts';
2
3
  import { createPrimaCommands } from '../src/cli.ts';
3
4
 
4
5
  const program = createPrimaCommands('prima');
6
+ knowledgeOption.register(program);
5
7
  program.parse();
@@ -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';
@@ -116,6 +117,7 @@ function addCommonOptions(cmd: Command): Command {
116
117
  .option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
117
118
  .option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
118
119
  .option('--url <url>', 'Page to open when the session has no page yet')
120
+ .option('--spec <path>', 'Docbot application spec directory or index.md to read as page knowledge')
119
121
  .option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
120
122
  .option('--pw-session <title>', 'Title of the playwright-cli session to attach to')
121
123
  .addHelpText('after', `\n${sessionHelp}`);
@@ -126,6 +128,7 @@ function primaFor(options: any): Prima {
126
128
  if (options.ephemeral) process.env.EXPLORBOT_EPHEMERAL = '1';
127
129
  if (options.model) process.env.EXPLORBOT_AI_MODEL = options.model;
128
130
  if (options.visionModel) process.env.EXPLORBOT_VISION_MODEL = options.visionModel;
131
+ if (options.spec) process.env.EXPLORBOT_SPEC = options.spec;
129
132
  return new Prima(buildOptions(options));
130
133
  }
131
134
 
@@ -218,6 +221,8 @@ export function createPrimaCommands(name = 'prima'): Command {
218
221
  process.exit(0);
219
222
  });
220
223
 
224
+ RecommendedModelsCommand.register(cmd);
225
+
221
226
  addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
222
227
  .addHelpText('after', `\n${statusHelp}`)
223
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