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
@@ -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) {
@@ -56,6 +56,21 @@ api: {
56
56
 
57
57
  A matching `teardown` hook runs after all tests finish — use it to clean up data.
58
58
 
59
+ ### Without a config file
60
+
61
+ Chief and Curler need three things: where the API is, what its spec says, and how to authenticate. Pass all three on the command line and no config file is needed:
62
+
63
+ ```bash
64
+ npx explorbot api plan /users \
65
+ --endpoint https://api.example.com/v1 \
66
+ --spec ./openapi.yaml \
67
+ --knowledge 'Send X-Api-Key: ${env.API_KEY} on every request'
68
+ ```
69
+
70
+ `--endpoint` and `--spec` each have an environment twin — `EXPLORBOT_URL` and `EXPLORBOT_API_SPEC` — and the flag wins when both are set. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
71
+
72
+ `--endpoint` keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads it from the flag or the variable.
73
+
59
74
  ### A dedicated API project
60
75
 
61
76
  If you don't have a web `explorbot.config.js`, run `npx explorbot api init`. It asks for your base endpoint, spec, and a one-line description of the API, then writes a standalone `apibot.config.ts` (with an `ai` and `api` section) plus `output/` and `knowledge/` directories. When both files exist, `apibot.config.*` takes precedence over `explorbot.config.*`.
@@ -21,7 +21,16 @@ endpoint: "/users"
21
21
  CRUD for users. Admin role required for writes. IDs are UUIDs.
22
22
  ```
23
23
 
24
- Chief loads knowledge matching the endpoint it's planning. Running `know` again on the same endpoint appends to the file. See [knowledge](../workflow/knowledge.md) for how matching and files work.
24
+ Chief loads knowledge matching the endpoint it's planning, and Curler loads it again for the endpoint it's testing, so auth headers and payload rules reach the requests themselves. Running `know` again on the same endpoint appends to the file. See [knowledge](../workflow/knowledge.md) for how matching and files work.
25
+
26
+ For a fact that should not be stored — a token, a one-off fixture — pass `--knowledge` instead. It applies to the run only:
27
+
28
+ ```bash
29
+ npx explorbot api explore /users --knowledge '---
30
+ endpoint: /users/*
31
+ ---
32
+ Send X-Api-Key: ${env.API_KEY} on every request'
33
+ ```
25
34
 
26
35
  ## Choose a planning style
27
36
 
@@ -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
+ ```