explorbot 0.4.1 → 0.4.3

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 (141) hide show
  1. package/bin/explorbot-cli.ts +14 -4
  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 +14 -4
  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 +12 -0
  35. package/dist/src/ai/fisherman/request-haul.js +31 -0
  36. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  37. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
  38. package/dist/src/ai/fisherman.d.ts +14 -4
  39. package/dist/src/ai/fisherman.js +96 -18
  40. package/dist/src/ai/pilot.d.ts +13 -1
  41. package/dist/src/ai/pilot.js +40 -22
  42. package/dist/src/ai/provider.js +8 -14
  43. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  44. package/dist/src/ai/researcher/coordinates.js +13 -1
  45. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  46. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  47. package/dist/src/ai/researcher.js +1 -1
  48. package/dist/src/ai/rules.js +10 -0
  49. package/dist/src/ai/tester.js +20 -20
  50. package/dist/src/ai/tools.d.ts +1 -1
  51. package/dist/src/ai/tools.js +18 -74
  52. package/dist/src/api/request-result.js +3 -1
  53. package/dist/src/api/request-store.d.ts +7 -6
  54. package/dist/src/api/request-store.js +65 -37
  55. package/dist/src/api/xhr-capture.d.ts +2 -0
  56. package/dist/src/api/xhr-capture.js +35 -10
  57. package/dist/src/commands/config-command.js +2 -9
  58. package/dist/src/commands/help-json-command.d.ts +31 -0
  59. package/dist/src/commands/help-json-command.js +58 -0
  60. package/dist/src/commands/index.js +2 -0
  61. package/dist/src/commands/init-command.js +3 -7
  62. package/dist/src/commands/plans-command.d.ts +4 -1
  63. package/dist/src/commands/plans-command.js +4 -4
  64. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  65. package/dist/src/commands/recommended-models-command.js +85 -0
  66. package/dist/src/config.d.ts +2 -0
  67. package/dist/src/config.js +7 -2
  68. package/dist/src/experience-tracker.js +1 -1
  69. package/dist/src/explorbot.js +3 -3
  70. package/dist/src/global-config.d.ts +2 -0
  71. package/dist/src/global-config.js +6 -0
  72. package/dist/src/state-manager.d.ts +3 -3
  73. package/dist/src/state-manager.js +8 -8
  74. package/dist/src/test-plan.d.ts +1 -0
  75. package/dist/src/test-plan.js +13 -9
  76. package/dist/src/utils/aria.d.ts +1 -1
  77. package/dist/src/utils/aria.js +23 -2
  78. package/dist/src/utils/cli-section.d.ts +1 -0
  79. package/dist/src/utils/cli-section.js +12 -0
  80. package/dist/src/utils/html-diff.d.ts +1 -0
  81. package/dist/src/utils/html-diff.js +5 -5
  82. package/dist/src/utils/html.d.ts +2 -0
  83. package/dist/src/utils/html.js +5 -4
  84. package/dist/src/utils/overlay.d.ts +19 -55
  85. package/dist/src/utils/overlay.js +94 -148
  86. package/dist/src/utils/region.d.ts +41 -0
  87. package/dist/src/utils/region.js +99 -0
  88. package/dist/src/utils/similarity.d.ts +1 -0
  89. package/dist/src/utils/similarity.js +11 -0
  90. package/dist/src/utils/url-matcher.d.ts +1 -1
  91. package/dist/src/utils/url-matcher.js +4 -4
  92. package/docs/doc-collection/basics.md +11 -5
  93. package/docs/doc-collection/crawling.md +21 -1
  94. package/docs/reference/commands.md +4 -0
  95. package/docs/reference/configuration.md +4 -0
  96. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  97. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  98. package/docs/workflow/agentic-usage.md +12 -0
  99. package/docs/workflow/application-spec.md +4 -0
  100. package/package.json +1 -1
  101. package/src/action-result.ts +15 -16
  102. package/src/action.ts +16 -15
  103. package/src/ai/fisherman/request-haul.ts +36 -0
  104. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
  105. package/src/ai/fisherman.ts +110 -20
  106. package/src/ai/pilot.ts +38 -20
  107. package/src/ai/provider.ts +8 -12
  108. package/src/ai/researcher/coordinates.ts +13 -1
  109. package/src/ai/researcher/deep-analysis.ts +9 -9
  110. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  111. package/src/ai/researcher.ts +1 -1
  112. package/src/ai/rules.ts +10 -0
  113. package/src/ai/tester.ts +18 -18
  114. package/src/ai/tools.ts +18 -86
  115. package/src/api/request-result.ts +2 -1
  116. package/src/api/request-store.ts +68 -42
  117. package/src/api/xhr-capture.ts +39 -11
  118. package/src/commands/config-command.ts +2 -7
  119. package/src/commands/help-json-command.ts +74 -0
  120. package/src/commands/index.ts +2 -0
  121. package/src/commands/init-command.ts +4 -11
  122. package/src/commands/plans-command.ts +5 -5
  123. package/src/commands/recommended-models-command.ts +92 -0
  124. package/src/config.ts +9 -2
  125. package/src/experience-tracker.ts +1 -1
  126. package/src/explorbot.ts +3 -3
  127. package/src/global-config.ts +8 -0
  128. package/src/state-manager.ts +10 -9
  129. package/src/test-plan.ts +12 -10
  130. package/src/utils/aria.ts +26 -3
  131. package/src/utils/cli-section.ts +12 -0
  132. package/src/utils/html-diff.ts +6 -6
  133. package/src/utils/html.ts +6 -4
  134. package/src/utils/overlay.ts +105 -192
  135. package/src/utils/region.ts +132 -0
  136. package/src/utils/similarity.ts +9 -0
  137. package/src/utils/url-matcher.ts +3 -3
  138. package/dist/src/ai/fisherman-tools.d.ts +0 -138
  139. package/dist/src/utils/request-map.d.ts +0 -7
  140. package/dist/src/utils/request-map.js +0 -16
  141. package/src/utils/request-map.ts +0 -19
@@ -1,62 +1,34 @@
1
1
  import { detectFocusArea } from './aria.js';
2
- import { pathToXPath } from './html-diff.js';
3
- import { extractHeadings } from './html.js';
2
+ import { HTML_SELECTORS } from './html.js';
4
3
  import { createDebug } from './logger.js';
4
+ import { Region, addedSubtrees } from './region.js';
5
5
  const debugLog = createDebug('explorbot:overlay');
6
- export class Overlay {
7
- type;
8
- name;
9
- root;
10
- html;
11
- xpath;
12
- parent;
6
+ export class Overlay extends Region {
13
7
  constructor(data = {}) {
14
- this.type = data.type ?? null;
15
- this.name = data.name ?? null;
16
- this.root = data.root ?? null;
17
- this.html = data.html ?? null;
18
- this.xpath = data.xpath ?? null;
19
- this.parent = data.parent ?? null;
8
+ super({ ...data, type: 'overlay' });
20
9
  }
21
- get detected() {
22
- return this.type !== null && this.type !== 'region';
23
- }
24
- get present() {
25
- return this.type !== null;
26
- }
27
- describe() {
28
- if (!this.present)
29
- return '';
30
- let text = `${this.type} "${this.name || 'unnamed'}" opened`;
31
- if (this.root)
32
- text += `, scope: ${this.root}`;
33
- return text;
10
+ get isModal() {
11
+ return true;
34
12
  }
35
13
  withGeometry(geometry) {
36
14
  return new Overlay({
37
- type: this.type,
38
15
  name: this.name || geometry.name,
39
16
  root: geometry.root,
40
17
  html: geometry.html,
41
18
  xpath: geometry.xpath,
42
19
  });
43
20
  }
44
- withParent(parent) {
45
- return new Overlay({
46
- type: this.type,
47
- name: this.name,
48
- root: this.root,
49
- html: this.html,
50
- xpath: this.xpath,
51
- parent: { type: parent.type, name: parent.name, root: parent.root, xpath: parent.xpath },
52
- });
53
- }
54
21
  static fromAria(snapshot) {
55
- return new Overlay(detectFocusArea(snapshot));
22
+ const focus = detectFocusArea(snapshot);
23
+ if (!focus.type)
24
+ return new Region();
25
+ return new Overlay({ name: focus.name });
56
26
  }
57
27
  static resolve(data) {
58
- if (data.overlay)
28
+ if (data.overlay?.type === 'overlay')
59
29
  return new Overlay(data.overlay);
30
+ if (data.overlay)
31
+ return new Region(data.overlay);
60
32
  return Overlay.fromAria(data.ariaSnapshot ?? null);
61
33
  }
62
34
  }
@@ -68,146 +40,120 @@ export class OverlayPage {
68
40
  async detectRegion(diff) {
69
41
  if (!diff.sameUrl && diff.similarity < SOFT_NAVIGATION_SIMILARITY)
70
42
  return null;
71
- for (const subRoot of this.appearedSubRoots(diff)) {
72
- const probe = await this.probe(subRoot.elementXPath);
73
- if (probe?.found && probe.onScreen && !probe.centerBelongs) {
74
- debugLog('Appeared region is hidden or covered, trying the next candidate');
43
+ for (const added of addedSubtrees(diff)) {
44
+ const layout = await this.measure(added.xpath);
45
+ if (layout?.rendered && layout.inViewport && !layout.centerBelongs) {
46
+ debugLog('Added content is hidden or covered, trying the next candidate');
47
+ continue;
48
+ }
49
+ const overlaying = !!layout?.rendered && layout.outOfFlow && layout.inViewport;
50
+ if (overlaying && modalScore(layout) < MIN_MODAL_SCORE) {
51
+ debugLog('Out-of-flow content is a popover, not an overlay; ignoring it');
75
52
  continue;
76
53
  }
77
- const overlay = this.toOverlay(subRoot, probe, diff.previousHtml);
78
- if (!overlay.name && !overlay.root) {
79
- debugLog('Appeared region has no name and no semantic root, ignoring it');
54
+ if (!overlaying && !added.sizable) {
55
+ debugLog('In-flow content is under the region floor, ignoring it');
80
56
  continue;
81
57
  }
82
- debugLog(`Region detected: ${overlay.describe()}`);
83
- return overlay;
58
+ const data = { name: added.name, root: added.root, html: added.html, xpath: added.xpath };
59
+ let region = new Region({ ...data, type: 'region' });
60
+ if (overlaying)
61
+ region = new Overlay(data);
62
+ debugLog(`Region detected: ${region.describe()}`);
63
+ return region;
84
64
  }
85
65
  return null;
86
66
  }
87
- async isStillOpen(overlay) {
88
- if (!overlay.xpath)
67
+ async isStillOpen(region) {
68
+ if (!region.xpath)
89
69
  return true;
90
- const probe = await this.probe(overlay.xpath);
70
+ const probe = await this.measure(region.xpath);
91
71
  if (!probe)
92
72
  return true;
93
- if (!probe.found)
73
+ if (!probe.rendered)
94
74
  return false;
95
- if (probe.onScreen && !probe.centerBelongs && !probe.coveredByFloating)
75
+ if (probe.inViewport && !probe.centerBelongs && !probe.coveredOutOfFlow)
96
76
  return false;
97
77
  return true;
98
78
  }
99
- appearedSubRoots(diff) {
100
- const candidates = [];
101
- for (const part of diff.parts) {
102
- const appeared = part.added.find((line) => line.startsWith('ELEMENT:'));
103
- if (!appeared)
104
- continue;
105
- if (part.subtree.length < SUBROOT_MIN_HTML)
106
- continue;
107
- if (diff.pageSize > 0 && part.rawSize > REGION_MAX_RATIO * diff.pageSize) {
108
- debugLog(`Appeared subtree spans ${Math.round((part.rawSize / diff.pageSize) * 100)}% of the page — a new state, not a region`);
109
- continue;
110
- }
111
- candidates.push({
112
- container: part.container,
113
- elementXPath: pathToXPath(appeared.slice('ELEMENT:'.length)),
114
- subtree: part.subtree,
115
- size: part.subtree.length,
116
- rawSize: part.rawSize,
117
- appearedSelector: part.appearedSelector,
118
- fresh: !!this.freshHeading(part.subtree, diff.previousHtml),
119
- });
120
- }
121
- if (candidates.length === 0)
122
- return [];
123
- const totalRawSize = candidates.reduce((sum, c) => sum + c.rawSize, 0);
124
- const largest = Math.max(...candidates.map((c) => c.rawSize));
125
- if (largest < REGION_DOMINANCE * totalRawSize) {
126
- debugLog('Changes are scattered across the page, no dominant region');
127
- return [];
128
- }
129
- candidates.sort((a, b) => Number(b.fresh) - Number(a.fresh) || b.size - a.size);
130
- return candidates;
131
- }
132
- async probe(xpath) {
79
+ async measure(xpath) {
133
80
  if (!this.page)
134
81
  return null;
135
- return this.page
136
- .evaluate(({ probeSource, config }) => {
137
- const probe = new Function(`return ${probeSource}`)();
138
- return probe(config);
139
- }, { probeSource: inspectRegion.toString(), config: { xpath } })
140
- .catch((err) => {
141
- debugLog('Region probe failed:', err.message);
82
+ return this.page.evaluate(measureLayout, { config: { xpath, controlsSelector: HTML_SELECTORS.interactiveControl } }).catch((err) => {
83
+ debugLog('Layout measurement failed:', err.message);
142
84
  return null;
143
85
  });
144
86
  }
145
- toOverlay(subRoot, probe, previousHtml) {
146
- let type = 'region';
147
- if (probe?.centerBelongs && probe.floating)
148
- type = 'modal';
149
- let root = null;
150
- if (subRoot.container !== 'body' && !subRoot.container.startsWith('//'))
151
- root = subRoot.container;
152
- if (!root && subRoot.appearedSelector)
153
- root = subRoot.appearedSelector;
154
- return new Overlay({ type, name: this.nameFrom(subRoot.subtree, previousHtml), root, html: subRoot.subtree, xpath: subRoot.elementXPath });
155
- }
156
- nameFrom(html, previousHtml) {
157
- const fresh = this.freshHeading(html, previousHtml);
158
- if (fresh)
159
- return fresh;
160
- const candidates = this.headingsOf(html);
161
- if (candidates.length === 0)
162
- return null;
163
- return candidates.join(' ');
164
- }
165
- freshHeading(html, previousHtml) {
166
- const fresh = this.headingsOf(html).filter((heading) => !previousHtml.includes(heading));
167
- return fresh[0] ?? null;
168
- }
169
- headingsOf(html) {
170
- const headings = extractHeadings(html);
171
- return [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean);
172
- }
173
87
  }
174
- const SUBROOT_MIN_HTML = 5_000;
175
88
  const SOFT_NAVIGATION_SIMILARITY = 50;
176
- const REGION_MAX_RATIO = 0.6;
177
- const REGION_DOMINANCE = 0.7;
178
- // Serialized via toString() into page.evaluate — must stay a plain function with no outer-scope references.
179
- function inspectRegion(config) {
180
- const probe = { found: false, onScreen: false, floating: false, centerBelongs: false, coveredByFloating: false };
181
- const isFloating = (start) => {
89
+ const TYPICAL_DIALOG_CONTROLS = 3;
90
+ const MIN_MODAL_SCORE = 0.25;
91
+ const modalScore = (layout) => {
92
+ if (!layout)
93
+ return 0;
94
+ let blocked = layout.viewportCoverage;
95
+ if (layout.holdsViewportCenter)
96
+ blocked = 1;
97
+ return blocked * Math.min(1, layout.controls / TYPICAL_DIALOG_CONTROLS);
98
+ };
99
+ // Passed to page.evaluate as a function — must stay self-contained with no outer-scope references.
100
+ function measureLayout({ config }) {
101
+ const layout = { rendered: false, inViewport: false, outOfFlow: false, centerBelongs: false, coveredOutOfFlow: false, holdsViewportCenter: false, viewportCoverage: 0, controls: 0 };
102
+ const isOutOfFlow = (start) => {
182
103
  let node = start;
183
104
  while (node && node !== document.body) {
184
- const style = window.getComputedStyle(node);
185
- if (style.position === 'fixed' || style.position === 'absolute' || (Number.parseInt(style.zIndex || '0', 10) || 0) > 0)
105
+ const position = window.getComputedStyle(node).position;
106
+ if (position === 'fixed' || position === 'absolute')
186
107
  return true;
187
108
  node = node.parentElement;
188
109
  }
189
110
  return false;
190
111
  };
112
+ const clip = (rect) => {
113
+ const left = Math.max(rect.left, 0);
114
+ const top = Math.max(rect.top, 0);
115
+ const right = Math.min(rect.right, window.innerWidth);
116
+ const bottom = Math.min(rect.bottom, window.innerHeight);
117
+ return { left, top, right, bottom, area: Math.max(0, right - left) * Math.max(0, bottom - top) };
118
+ };
191
119
  const result = document.evaluate(config.xpath, document, null, 9, null);
192
120
  const node = result.singleNodeValue;
193
121
  if (!node || node.nodeType !== 1)
194
- return probe;
122
+ return layout;
195
123
  const element = node;
196
- const rect = element.getBoundingClientRect();
124
+ const belongs = (hit) => !!hit && (hit === element || element.contains(hit));
125
+ let box = element;
126
+ let outOfFlow = isOutOfFlow(element);
127
+ let boxArea = 0;
128
+ if (outOfFlow)
129
+ boxArea = clip(element.getBoundingClientRect()).area;
130
+ const MAX_DESCENDANTS_SCANNED = 200;
131
+ const descendants = Array.from(element.querySelectorAll('*')).slice(0, MAX_DESCENDANTS_SCANNED);
132
+ for (const descendant of descendants) {
133
+ if (!isOutOfFlow(descendant))
134
+ continue;
135
+ const area = clip(descendant.getBoundingClientRect()).area;
136
+ if (area <= boxArea)
137
+ continue;
138
+ box = descendant;
139
+ boxArea = area;
140
+ outOfFlow = true;
141
+ }
142
+ const rect = box.getBoundingClientRect();
197
143
  if (rect.width === 0 && rect.height === 0)
198
- return probe;
199
- probe.found = true;
200
- probe.floating = isFloating(element);
201
- const left = Math.max(rect.left, 0);
202
- const top = Math.max(rect.top, 0);
203
- const right = Math.min(rect.right, window.innerWidth);
204
- const bottom = Math.min(rect.bottom, window.innerHeight);
144
+ return layout;
145
+ layout.rendered = true;
146
+ layout.outOfFlow = outOfFlow;
147
+ layout.controls = Array.from(element.querySelectorAll(config.controlsSelector)).filter((control) => control.getClientRects().length > 0).length;
148
+ const { left, top, right, bottom, area } = clip(rect);
205
149
  if (right <= left || bottom <= top)
206
- return probe;
207
- probe.onScreen = true;
150
+ return layout;
151
+ layout.inViewport = true;
152
+ layout.viewportCoverage = area / (window.innerWidth * window.innerHeight);
208
153
  const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
209
- probe.centerBelongs = !!hit && (hit === element || element.contains(hit));
210
- if (hit && !probe.centerBelongs)
211
- probe.coveredByFloating = isFloating(hit);
212
- return probe;
154
+ layout.centerBelongs = belongs(hit);
155
+ if (hit && !layout.centerBelongs)
156
+ layout.coveredOutOfFlow = isOutOfFlow(hit);
157
+ layout.holdsViewportCenter = belongs(document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2));
158
+ return layout;
213
159
  }
@@ -0,0 +1,41 @@
1
+ import type { HtmlDiffPart } from './html-diff.js';
2
+ export declare class Region {
3
+ readonly type: RegionType | null;
4
+ readonly name: string | null;
5
+ readonly root: string | null;
6
+ readonly html: string | null;
7
+ readonly xpath: string | null;
8
+ readonly parent: RegionData | null;
9
+ constructor(data?: RegionData);
10
+ get isOpen(): boolean;
11
+ get isModal(): boolean;
12
+ describe(): string;
13
+ withParent(parent: Region): Region;
14
+ }
15
+ export declare function addedSubtrees(diff: RegionDiff): AddedSubtree[];
16
+ export type RegionType = 'overlay' | 'region';
17
+ export type RegionData = {
18
+ type?: RegionType | null;
19
+ name?: string | null;
20
+ root?: string | null;
21
+ html?: string | null;
22
+ xpath?: string | null;
23
+ parent?: RegionData | null;
24
+ };
25
+ export interface RegionDiff {
26
+ parts: HtmlDiffPart[];
27
+ pageSize: number;
28
+ similarity: number;
29
+ sameUrl: boolean;
30
+ previousHtml: string;
31
+ }
32
+ export interface AddedSubtree {
33
+ xpath: string;
34
+ html: string;
35
+ size: number;
36
+ rawSize: number;
37
+ sizable: boolean;
38
+ name: string | null;
39
+ root: string | null;
40
+ hasNewHeading: boolean;
41
+ }
@@ -0,0 +1,99 @@
1
+ import { pathToXPath } from './html-diff.js';
2
+ import { extractHeadings } from './html.js';
3
+ import { createDebug } from './logger.js';
4
+ const debugLog = createDebug('explorbot:region');
5
+ export class Region {
6
+ type;
7
+ name;
8
+ root;
9
+ html;
10
+ xpath;
11
+ parent;
12
+ constructor(data = {}) {
13
+ this.type = data.type ?? null;
14
+ this.name = data.name ?? null;
15
+ this.root = data.root ?? null;
16
+ this.html = data.html ?? null;
17
+ this.xpath = data.xpath ?? null;
18
+ this.parent = data.parent ?? null;
19
+ }
20
+ get isOpen() {
21
+ return this.type !== null;
22
+ }
23
+ get isModal() {
24
+ return false;
25
+ }
26
+ describe() {
27
+ if (!this.isOpen)
28
+ return '';
29
+ let text = `${this.type} "${this.name || 'unnamed'}" opened`;
30
+ if (this.root)
31
+ text += `, scope: ${this.root}`;
32
+ return text;
33
+ }
34
+ withParent(parent) {
35
+ const Ctor = this.constructor;
36
+ return new Ctor({
37
+ type: this.type,
38
+ name: this.name,
39
+ root: this.root,
40
+ html: this.html,
41
+ xpath: this.xpath,
42
+ parent: { type: parent.type, name: parent.name, root: parent.root, xpath: parent.xpath },
43
+ });
44
+ }
45
+ }
46
+ export function addedSubtrees(diff) {
47
+ const candidates = [];
48
+ for (const part of diff.parts) {
49
+ const appeared = part.added.find((line) => line.startsWith('ELEMENT:'));
50
+ if (!appeared)
51
+ continue;
52
+ if (diff.pageSize > 0 && part.rawSize > REGION_MAX_RATIO * diff.pageSize) {
53
+ debugLog(`Added subtree spans ${Math.round((part.rawSize / diff.pageSize) * 100)}% of the page — a new state, not a region`);
54
+ continue;
55
+ }
56
+ const headings = headingsOf(part.subtree);
57
+ const newHeading = headings.find((heading) => !diff.previousHtml.includes(heading)) ?? null;
58
+ let name = newHeading;
59
+ if (!name && headings.length > 0)
60
+ name = headings.join(' ');
61
+ const namedContainer = part.container !== 'body' && !part.container.startsWith('//');
62
+ let root = null;
63
+ if (namedContainer && part.containerWasEmpty)
64
+ root = part.container;
65
+ if (!root)
66
+ root = part.appearedSelector ?? null;
67
+ if (!root && namedContainer)
68
+ root = part.container;
69
+ candidates.push({
70
+ xpath: pathToXPath(appeared.slice('ELEMENT:'.length)),
71
+ html: part.subtree,
72
+ size: part.subtree.length,
73
+ rawSize: part.rawSize,
74
+ sizable: part.subtree.length >= REGION_MIN_HTML,
75
+ name,
76
+ root,
77
+ hasNewHeading: !!newHeading,
78
+ });
79
+ }
80
+ const sizable = candidates.filter((candidate) => candidate.sizable);
81
+ const totalRawSize = sizable.reduce((sum, candidate) => sum + candidate.rawSize, 0);
82
+ const largest = Math.max(0, ...sizable.map((candidate) => candidate.rawSize));
83
+ if (largest < REGION_DOMINANCE * totalRawSize) {
84
+ debugLog('Changes are scattered across the page, no dominant region');
85
+ return [];
86
+ }
87
+ const identified = candidates.filter((candidate) => candidate.name || candidate.root);
88
+ if (identified.length < candidates.length)
89
+ debugLog('Dropped added content with no name and no scoping root');
90
+ identified.sort((a, b) => Number(b.hasNewHeading) - Number(a.hasNewHeading) || b.size - a.size);
91
+ return identified;
92
+ }
93
+ const headingsOf = (html) => {
94
+ const headings = extractHeadings(html);
95
+ return [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean);
96
+ };
97
+ const REGION_MIN_HTML = 5_000;
98
+ const REGION_MAX_RATIO = 0.6;
99
+ const REGION_DOMINANCE = 0.7;
@@ -0,0 +1 @@
1
+ export declare function diceSimilarity(a: Set<string>, b: Set<string>): number;
@@ -0,0 +1,11 @@
1
+ export function diceSimilarity(a, b) {
2
+ let intersection = 0;
3
+ for (const item of a) {
4
+ if (b.has(item))
5
+ intersection++;
6
+ }
7
+ const total = a.size + b.size;
8
+ if (total === 0)
9
+ return 100;
10
+ return Math.round(((2 * intersection) / total) * 100);
11
+ }
@@ -2,7 +2,7 @@ export declare function isDynamicSegment(segment: string): boolean;
2
2
  export declare function hasDynamicUrlSegment(url: string): boolean;
3
3
  export declare function isSamePageFamily(urlA: string, urlB: string): boolean;
4
4
  export declare function generalizeSegment(segment: string): string;
5
- export declare function generalizeUrl(url: string): string;
5
+ export declare function generalizeUrl(url: string, replaceSegment?: (segment: string) => string): string;
6
6
  export declare function matchesUrl(pattern: string, path: string): boolean;
7
7
  export declare function extractStatePath(url: string): string;
8
8
  export declare function matchesNavigationUrl(expected: string, current: string): boolean;
@@ -3,8 +3,8 @@ import { ConfigParser } from '../config.js';
3
3
  export function isDynamicSegment(segment) {
4
4
  try {
5
5
  const configRegex = ConfigParser.getInstance().getConfig().dynamicPageRegex;
6
- if (configRegex)
7
- return new RegExp(configRegex, 'i').test(segment);
6
+ if (configRegex && new RegExp(configRegex, 'i').test(segment))
7
+ return true;
8
8
  }
9
9
  catch {
10
10
  /* config not loaded yet */
@@ -62,10 +62,10 @@ export function generalizeSegment(segment) {
62
62
  return '[a-f0-9]+';
63
63
  return '[^/]+';
64
64
  }
65
- export function generalizeUrl(url) {
65
+ export function generalizeUrl(url, replaceSegment = generalizeSegment) {
66
66
  return url
67
67
  .split('/')
68
- .map((seg) => (seg.length > 0 && isDynamicSegment(seg) ? generalizeSegment(seg) : seg))
68
+ .map((seg) => (seg.length > 0 && isDynamicSegment(seg) ? replaceSegment(seg) : seg))
69
69
  .join('/');
70
70
  }
71
71
  export function matchesUrl(pattern, path) {
@@ -105,22 +105,28 @@ Title: User Management
105
105
 
106
106
  Lists all user accounts and provides entry points for managing them.
107
107
 
108
- ## Screenshots
109
-
110
- ![Page screenshot](../screenshots/admin_users_page.png)
111
-
112
108
  ## User Can
113
109
 
114
110
  - user can search users by name -> list of items
115
111
  Proof: Search input above the users table.
112
+ ![user can search users by name](../screenshots/admin_users_can_1.png)
116
113
 
117
114
  ## User Might
118
115
 
119
116
  - user might export the user list -> all items
120
117
  Signal: Toolbar shows an unlabeled download icon.
118
+
119
+ ## Screenshots
120
+
121
+ ![Page screenshot](../screenshots/admin_users_page.png)
122
+
123
+ ## Navigation
124
+
125
+ - Create user: /admin/users/new
126
+ - Audit log: /admin/audit
121
127
  ```
122
128
 
123
- `User Can` lists capabilities proven by visible UI, each with its evidence. `User Might` lists capabilities the UI suggests but research could not fully confirm. Pages where research found almost nothing are skipped rather than padded with guesses.
129
+ `User Can` lists capabilities proven by visible UI, each with its evidence. `User Might` lists capabilities the UI suggests but research could not fully confirm. Links to other pages are collected in their own `Navigation` section and never counted as capabilities. Pages where research found almost nothing are skipped rather than padded with guesses.
124
130
 
125
131
  ## Next steps
126
132
 
@@ -4,7 +4,7 @@ With defaults, a run crawls everything it can reach on the same origin, up to 10
4
4
 
5
5
  ## How the queue is built
6
6
 
7
- After documenting a page, the crawler queues new targets from three sources: links found on the page, navigation entries identified by research (this is how hash-navigated pages such as OpenAPI reference docs get crawled), and URLs discovered through clicks when [interactive mode](./interactive-mode.md) is on. Every target must pass the filters on this page before it is queued. Pages already visited in this session are not revisited, and the crawl stops early if it detects a dead loop.
7
+ After documenting a page, the crawler queues new targets from three sources: links found on the page, navigation entries identified by research (this is how hash-navigated pages such as OpenAPI reference docs get crawled), and URLs discovered through clicks when [interactive mode](./interactive-mode.md) is on. Every target must pass the filters on this page before it is queued. Pages already visited in this session are not revisited.
8
8
 
9
9
  ## maxPages — the budget
10
10
 
@@ -65,3 +65,23 @@ docs: {
65
65
  ```
66
66
 
67
67
  Set it to `false` when such pages genuinely differ and you want each URL documented separately. Expect the page budget to fill up faster.
68
+
69
+ ## collapseTemplatePages — skip pages with a documented layout
70
+
71
+ URL collapsing cannot see slugs: `/blog/my-post` and `/blog/other-post` look like different pages, yet they share one layout and differ only in their text. By default, after a page is loaded the crawler compares the page structure — the tree of ARIA roles without any text — against the pages it has already documented. A page whose layout matches one of them is skipped and listed under Skipped in the index with the reason `same layout as <url> (only content differs)`. Skipped pages do not consume the page budget.
72
+
73
+ The first page of each layout is always documented in full; only its clones are skipped.
74
+
75
+ A page counts as a clone when its structure is at least 90% similar to a documented page. Identical templates score close to 100; pages that merely look alike (same kind of form or list, different purpose) score around 80 and are still documented. If your site leans either way, move the bar with `docs.templateSimilarity` or per-run with `--template-similarity <percent>`; invalid values fall back to the default.
76
+
77
+ ```ts
78
+ docs: {
79
+ collapseTemplatePages: false,
80
+ }
81
+ ```
82
+
83
+ For a one-off run over every page, use the CLI flag instead:
84
+
85
+ ```bash
86
+ explorbot docs collect /blog --no-collapse-template-pages
87
+ ```
@@ -52,6 +52,8 @@ Inside the TUI, use the matching slash command: `/explore`, `/research`, `/plan`
52
52
  | Initialize project | `npx explorbot init` | — | Generates `explorbot.config.*`, or `~/.explorbot` with `--global` |
53
53
  | List registered sites | `npx explorbot sites` | — | Sites stored in the global installation |
54
54
  | Show resolved configuration | `npx explorbot config [url] [--json]` | `/config` | Models, config file, paths and `EXPLORBOT_*` in effect |
55
+ | Show recommended models | `npx explorbot recommended-models [--json]` | `/recommended-models` | Models this version recommends per provider |
56
+ | Describe commands | `npx explorbot help-json [command...]` | — | The command tree as JSON: arguments, options, defaults |
55
57
  | Clean generated files | `npx explorbot clean [target]` | `/clean [target]` | Same targets both ways |
56
58
 
57
59
  ## Common CLI Options
@@ -122,6 +124,8 @@ EXPLORBOT_AI_PROVIDER=openrouter \
122
124
 
123
125
  `npx explorbot config` prints the values a run actually uses — models per role, the config file behind them, the output, knowledge and experience directories, and every `EXPLORBOT_*` variable currently set. The boats answer for their own configuration the same way: `npx explorbot api config`, `npx explorbot docs config`, `npx explorbot prima config`. Add `--json` on any of them to get the same values as an object a script can read.
124
126
 
127
+ `npx explorbot recommended-models` prints, per provider, the model this version recommends for each role, and the two ways to select it. Both need the provider's API key exported. Set `EXPLORBOT_AI_PROVIDER=<name>` and every role takes that provider's recommendation; leave it out and pin the roles yourself with `EXPLORBOT_AI_MODEL`, `EXPLORBOT_VISION_MODEL` and `EXPLORBOT_AGENTIC_MODEL`, each written as `provider/model-id` — the command prints those three lines filled in, ready to paste. A role a provider does not serve is named as such, so you know to pair it with another. It closes with the model variables and provider keys currently exported, and a ready-to-run OpenRouter one-liner. It reads nothing but the bundled recommendations, so it answers before any configuration exists and every CLI carries it: `npx explorbot api recommended-models`, `npx explorbot docs recommended-models`, `npx prima recommended-models`. `--json` prints the bundled recommendations as an object.
128
+
125
129
  Explorbot resolves its configuration in this order: the path given to `--config`, then `explorbot.config.*` in the working directory, then the `EXPLORBOT_*` variables, and finally `~/.explorbot/config.*` from the global installation. A bare provider name fills every model role from the recommendations in [Providers](../basics/providers.md); a `provider/model-id` spec pins one model and splits on the first slash, so `openrouter/openai/gpt-oss-120b:nitro` selects OpenRouter with model `openai/gpt-oss-120b:nitro`. Supported providers: `openai`, `anthropic`, `google`, `groq`, `mistral`, `openrouter`, `sambanova`.
126
130
 
127
131
  In this mode output goes to `~/.explorbot/sites/<host>/output/` (or `EXPLORBOT_OUTPUT`, or a temp directory with `EXPLORBOT_EPHEMERAL=1`), experience is kept beside it unless the run is ephemeral, and the Historian is off, so no generated test files appear. See [Agentic Usage](../workflow/agentic-usage.md) for the full picture.
@@ -253,6 +253,10 @@ With `'playwright'`, runs are saved as `@playwright/test` `.spec.ts` files using
253
253
 
254
254
  See [AI providers](../basics/providers.md) for recommended models and provider setup.
255
255
 
256
+ ### Fisherman agent
257
+
258
+ Fisherman prepares test data over the API before a scenario runs, and can also answer questions about data that already exists without creating or changing anything. Pilot reaches this read-only capability through its `askApi(question)` tool, calling it to check whether suitable data is already there — or to get the exact name or id of an existing record — before deciding whether to create anything through `precondition()`. In replicate mode, where Fisherman learns the API by watching browser traffic instead of reading a spec, the read endpoints it can query come from successful GET requests observed in the browser, alongside the write endpoints already captured from XHR traffic. The endpoint list shown to the model names only the path and its query-parameter names, never their values; the underlying capture on disk holds the full request URL and headers — what write captures already hold — but no response body.
259
+
256
260
  ## Playwright settings
257
261
 
258
262
  ### Browser selection