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.
- package/bin/explorbot-cli.ts +14 -4
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/cli.ts +3 -0
- package/boat/doc-collector/src/ai/documentarian.ts +57 -5
- package/boat/doc-collector/src/ai/tools.ts +1 -1
- package/boat/doc-collector/src/cli.ts +18 -2
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +56 -11
- package/boat/doc-collector/src/docs-renderer.ts +79 -55
- package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
- package/boat/doc-collector/src/screenshots.ts +106 -3
- package/boat/doc-collector/src/template-dedup.ts +31 -0
- package/boat/prima/src/cli.ts +3 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +14 -4
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
- package/dist/boat/doc-collector/src/ai/tools.js +1 -1
- package/dist/boat/doc-collector/src/cli.js +15 -2
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +52 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
- package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
- package/dist/boat/doc-collector/src/screenshots.js +104 -1
- package/dist/boat/doc-collector/src/template-dedup.js +27 -0
- package/dist/boat/prima/src/cli.js +2 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +2 -2
- package/dist/src/action-result.js +15 -17
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +15 -15
- package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
- package/dist/src/ai/fisherman/request-haul.js +31 -0
- package/dist/src/ai/fisherman/tools.d.ts +50 -0
- package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
- package/dist/src/ai/fisherman.d.ts +14 -4
- package/dist/src/ai/fisherman.js +96 -18
- package/dist/src/ai/pilot.d.ts +13 -1
- package/dist/src/ai/pilot.js +40 -22
- package/dist/src/ai/provider.js +8 -14
- package/dist/src/ai/researcher/coordinates.d.ts +2 -0
- package/dist/src/ai/researcher/coordinates.js +13 -1
- package/dist/src/ai/researcher/deep-analysis.js +9 -9
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +10 -0
- package/dist/src/ai/tester.js +20 -20
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +18 -74
- package/dist/src/api/request-result.js +3 -1
- package/dist/src/api/request-store.d.ts +7 -6
- package/dist/src/api/request-store.js +65 -37
- package/dist/src/api/xhr-capture.d.ts +2 -0
- package/dist/src/api/xhr-capture.js +35 -10
- package/dist/src/commands/config-command.js +2 -9
- package/dist/src/commands/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/plans-command.d.ts +4 -1
- package/dist/src/commands/plans-command.js +4 -4
- package/dist/src/commands/recommended-models-command.d.ts +9 -0
- package/dist/src/commands/recommended-models-command.js +85 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +7 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +3 -3
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/state-manager.d.ts +3 -3
- package/dist/src/state-manager.js +8 -8
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +13 -9
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +23 -2
- package/dist/src/utils/cli-section.d.ts +1 -0
- package/dist/src/utils/cli-section.js +12 -0
- package/dist/src/utils/html-diff.d.ts +1 -0
- package/dist/src/utils/html-diff.js +5 -5
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +5 -4
- package/dist/src/utils/overlay.d.ts +19 -55
- package/dist/src/utils/overlay.js +94 -148
- package/dist/src/utils/region.d.ts +41 -0
- package/dist/src/utils/region.js +99 -0
- package/dist/src/utils/similarity.d.ts +1 -0
- package/dist/src/utils/similarity.js +11 -0
- package/dist/src/utils/url-matcher.d.ts +1 -1
- package/dist/src/utils/url-matcher.js +4 -4
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +4 -0
- package/docs/reference/configuration.md +4 -0
- package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/agentic-usage.md +12 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/fisherman/request-haul.ts +36 -0
- package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
- package/src/ai/fisherman.ts +110 -20
- package/src/ai/pilot.ts +38 -20
- package/src/ai/provider.ts +8 -12
- package/src/ai/researcher/coordinates.ts +13 -1
- package/src/ai/researcher/deep-analysis.ts +9 -9
- package/src/ai/researcher/fingerprint-worker.ts +1 -10
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +10 -0
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-result.ts +2 -1
- package/src/api/request-store.ts +68 -42
- package/src/api/xhr-capture.ts +39 -11
- package/src/commands/config-command.ts +2 -7
- package/src/commands/help-json-command.ts +74 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +9 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +3 -3
- package/src/global-config.ts +8 -0
- package/src/state-manager.ts +10 -9
- package/src/test-plan.ts +12 -10
- package/src/utils/aria.ts +26 -3
- package/src/utils/cli-section.ts +12 -0
- package/src/utils/html-diff.ts +6 -6
- package/src/utils/html.ts +6 -4
- package/src/utils/overlay.ts +105 -192
- package/src/utils/region.ts +132 -0
- package/src/utils/similarity.ts +9 -0
- package/src/utils/url-matcher.ts +3 -3
- package/dist/src/ai/fisherman-tools.d.ts +0 -138
- package/dist/src/utils/request-map.d.ts +0 -7
- package/dist/src/utils/request-map.js +0 -16
- package/src/utils/request-map.ts +0 -19
|
@@ -1,62 +1,34 @@
|
|
|
1
1
|
import { detectFocusArea } from './aria.js';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
22
|
-
return
|
|
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
|
-
|
|
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
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
debugLog('
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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(
|
|
88
|
-
if (!
|
|
67
|
+
async isStillOpen(region) {
|
|
68
|
+
if (!region.xpath)
|
|
89
69
|
return true;
|
|
90
|
-
const probe = await this.
|
|
70
|
+
const probe = await this.measure(region.xpath);
|
|
91
71
|
if (!probe)
|
|
92
72
|
return true;
|
|
93
|
-
if (!probe.
|
|
73
|
+
if (!probe.rendered)
|
|
94
74
|
return false;
|
|
95
|
-
if (probe.
|
|
75
|
+
if (probe.inViewport && !probe.centerBelongs && !probe.coveredOutOfFlow)
|
|
96
76
|
return false;
|
|
97
77
|
return true;
|
|
98
78
|
}
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
185
|
-
if (
|
|
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
|
|
122
|
+
return layout;
|
|
195
123
|
const element = node;
|
|
196
|
-
const
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const top =
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
if (hit && !
|
|
211
|
-
|
|
212
|
-
|
|
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;
|
|
@@ -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
|
|
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) ?
|
|
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
|
-

|
|
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
|
+

|
|
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
|
+

|
|
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
|
|
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
|