explorbot 0.3.5 → 0.4.0
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 +18 -13
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +3 -1
- package/boat/prima/src/cli.ts +21 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +19 -13
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +3 -1
- package/dist/boat/prima/src/cli.js +19 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +13 -1
- package/dist/src/ai/provider.js +20 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +27 -2
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.js +1 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +23 -36
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/reference/commands.md +8 -1
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +1 -0
- package/docs/workflow/ci.md +1 -0
- package/package.json +2 -2
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/pilot.ts +9 -1
- package/src/ai/provider.ts +21 -3
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/commands/explore-command.ts +25 -2
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +20 -36
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
package/src/utils/overlay.ts
CHANGED
|
@@ -1,51 +1,254 @@
|
|
|
1
1
|
import { detectFocusArea } from './aria.js';
|
|
2
|
-
import
|
|
2
|
+
import type { HtmlDiffPart } from './html-diff.js';
|
|
3
|
+
import { pathToXPath } from './html-diff.js';
|
|
4
|
+
import { extractHeadings } from './html.js';
|
|
5
|
+
import { createDebug } from './logger.js';
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
|
|
6
|
-
modalOverlays: ['[role="dialog"]', '[role="alertdialog"]', '[aria-modal="true"]', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
|
|
7
|
-
overlaySemanticSelector: '[role="dialog"], [role="alertdialog"], [aria-modal="true"], [role="listbox"], [role="menu"], [role="tooltip"]',
|
|
8
|
-
} as const;
|
|
7
|
+
const debugLog = createDebug('explorbot:overlay');
|
|
9
8
|
|
|
10
|
-
export type
|
|
9
|
+
export type OverlayType = 'modal' | 'region';
|
|
10
|
+
export type OverlayData = {
|
|
11
|
+
type?: OverlayType | null;
|
|
12
|
+
name?: string | null;
|
|
13
|
+
root?: string | null;
|
|
14
|
+
html?: string | null;
|
|
15
|
+
xpath?: string | null;
|
|
16
|
+
parent?: OverlayData | null;
|
|
17
|
+
};
|
|
11
18
|
|
|
12
19
|
export class Overlay {
|
|
13
|
-
readonly type:
|
|
20
|
+
readonly type: OverlayType | null;
|
|
14
21
|
readonly name: string | null;
|
|
22
|
+
readonly root: string | null;
|
|
23
|
+
readonly html: string | null;
|
|
24
|
+
readonly xpath: string | null;
|
|
25
|
+
readonly parent: OverlayData | null;
|
|
15
26
|
|
|
16
27
|
constructor(data: OverlayData = {}) {
|
|
17
28
|
this.type = data.type ?? null;
|
|
18
29
|
this.name = data.name ?? null;
|
|
30
|
+
this.root = data.root ?? null;
|
|
31
|
+
this.html = data.html ?? null;
|
|
32
|
+
this.xpath = data.xpath ?? null;
|
|
33
|
+
this.parent = data.parent ?? null;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
get detected(): boolean {
|
|
37
|
+
return this.type !== null && this.type !== 'region';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get present(): boolean {
|
|
22
41
|
return this.type !== null;
|
|
23
42
|
}
|
|
24
43
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
describe(): string {
|
|
45
|
+
if (!this.present) return '';
|
|
46
|
+
let text = `${this.type} "${this.name || 'unnamed'}" opened`;
|
|
47
|
+
if (this.root) text += `, scope: ${this.root}`;
|
|
48
|
+
return text;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
withGeometry(geometry: Overlay): Overlay {
|
|
52
|
+
return new Overlay({
|
|
53
|
+
type: this.type,
|
|
54
|
+
name: this.name || geometry.name,
|
|
55
|
+
root: geometry.root,
|
|
56
|
+
html: geometry.html,
|
|
57
|
+
xpath: geometry.xpath,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
withParent(parent: Overlay): Overlay {
|
|
62
|
+
return new Overlay({
|
|
63
|
+
type: this.type,
|
|
64
|
+
name: this.name,
|
|
65
|
+
root: this.root,
|
|
66
|
+
html: this.html,
|
|
67
|
+
xpath: this.xpath,
|
|
68
|
+
parent: { type: parent.type, name: parent.name, root: parent.root, xpath: parent.xpath },
|
|
69
|
+
});
|
|
29
70
|
}
|
|
30
71
|
|
|
31
72
|
static fromAria(snapshot: string | null): Overlay {
|
|
32
73
|
return new Overlay(detectFocusArea(snapshot));
|
|
33
74
|
}
|
|
34
75
|
|
|
35
|
-
static resolve(data: {
|
|
36
|
-
if (data.overlayHtml) return Overlay.fromHtml(data.overlayHtml);
|
|
76
|
+
static resolve(data: { overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay {
|
|
37
77
|
if (data.overlay) return new Overlay(data.overlay);
|
|
38
78
|
return Overlay.fromAria(data.ariaSnapshot ?? null);
|
|
39
79
|
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class OverlayPage {
|
|
83
|
+
constructor(private page: { evaluate(fn: any, arg: any): Promise<any> } | null) {}
|
|
84
|
+
|
|
85
|
+
async detectRegion(diff: RegionDiff): Promise<Overlay | null> {
|
|
86
|
+
if (!diff.sameUrl && diff.similarity < SOFT_NAVIGATION_SIMILARITY) return null;
|
|
87
|
+
for (const subRoot of this.appearedSubRoots(diff)) {
|
|
88
|
+
const probe = await this.probe(subRoot.elementXPath);
|
|
89
|
+
if (probe?.found && probe.onScreen && !probe.centerBelongs) {
|
|
90
|
+
debugLog('Appeared region is hidden or covered, trying the next candidate');
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const overlay = this.toOverlay(subRoot, probe, diff.previousHtml);
|
|
94
|
+
if (!overlay.name && !overlay.root) {
|
|
95
|
+
debugLog('Appeared region has no name and no semantic root, ignoring it');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
debugLog(`Region detected: ${overlay.describe()}`);
|
|
99
|
+
return overlay;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async isStillOpen(overlay: Overlay): Promise<boolean> {
|
|
105
|
+
if (!overlay.xpath) return true;
|
|
106
|
+
const probe = await this.probe(overlay.xpath);
|
|
107
|
+
if (!probe) return true;
|
|
108
|
+
if (!probe.found) return false;
|
|
109
|
+
if (probe.onScreen && !probe.centerBelongs && !probe.coveredByFloating) return false;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private appearedSubRoots(diff: RegionDiff): AppearedSubRoot[] {
|
|
114
|
+
const candidates: AppearedSubRoot[] = [];
|
|
115
|
+
for (const part of diff.parts) {
|
|
116
|
+
const appeared = part.added.find((line) => line.startsWith('ELEMENT:'));
|
|
117
|
+
if (!appeared) continue;
|
|
118
|
+
if (part.subtree.length < SUBROOT_MIN_HTML) continue;
|
|
119
|
+
if (diff.pageSize > 0 && part.rawSize > REGION_MAX_RATIO * diff.pageSize) {
|
|
120
|
+
debugLog(`Appeared subtree spans ${Math.round((part.rawSize / diff.pageSize) * 100)}% of the page — a new state, not a region`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
candidates.push({
|
|
124
|
+
container: part.container,
|
|
125
|
+
elementXPath: pathToXPath(appeared.slice('ELEMENT:'.length)),
|
|
126
|
+
subtree: part.subtree,
|
|
127
|
+
size: part.subtree.length,
|
|
128
|
+
rawSize: part.rawSize,
|
|
129
|
+
appearedSelector: part.appearedSelector,
|
|
130
|
+
fresh: !!this.freshHeading(part.subtree, diff.previousHtml),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (candidates.length === 0) return [];
|
|
134
|
+
const totalRawSize = candidates.reduce((sum, c) => sum + c.rawSize, 0);
|
|
135
|
+
const largest = Math.max(...candidates.map((c) => c.rawSize));
|
|
136
|
+
if (largest < REGION_DOMINANCE * totalRawSize) {
|
|
137
|
+
debugLog('Changes are scattered across the page, no dominant region');
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
candidates.sort((a, b) => Number(b.fresh) - Number(a.fresh) || b.size - a.size);
|
|
141
|
+
return candidates;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async probe(xpath: string): Promise<RegionProbe | null> {
|
|
145
|
+
if (!this.page) return null;
|
|
146
|
+
return this.page
|
|
147
|
+
.evaluate(
|
|
148
|
+
({ probeSource, config }: { probeSource: string; config: any }) => {
|
|
149
|
+
const probe = new Function(`return ${probeSource}`)() as (config: any) => any;
|
|
150
|
+
return probe(config);
|
|
151
|
+
},
|
|
152
|
+
{ probeSource: inspectRegion.toString(), config: { xpath } }
|
|
153
|
+
)
|
|
154
|
+
.catch((err: Error) => {
|
|
155
|
+
debugLog('Region probe failed:', err.message);
|
|
156
|
+
return null;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private toOverlay(subRoot: AppearedSubRoot, probe: RegionProbe | null, previousHtml: string): Overlay {
|
|
161
|
+
let type: OverlayType = 'region';
|
|
162
|
+
if (probe?.centerBelongs && probe.floating) type = 'modal';
|
|
163
|
+
let root: string | null = null;
|
|
164
|
+
if (subRoot.container !== 'body' && !subRoot.container.startsWith('//')) root = subRoot.container;
|
|
165
|
+
if (!root && subRoot.appearedSelector) root = subRoot.appearedSelector;
|
|
166
|
+
return new Overlay({ type, name: this.nameFrom(subRoot.subtree, previousHtml), root, html: subRoot.subtree, xpath: subRoot.elementXPath });
|
|
167
|
+
}
|
|
40
168
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
visibilityLimits: HTML_VISIBILITY_LIMITS,
|
|
48
|
-
geometryFallback: false,
|
|
49
|
-
};
|
|
169
|
+
private nameFrom(html: string, previousHtml: string): string | null {
|
|
170
|
+
const fresh = this.freshHeading(html, previousHtml);
|
|
171
|
+
if (fresh) return fresh;
|
|
172
|
+
const candidates = this.headingsOf(html);
|
|
173
|
+
if (candidates.length === 0) return null;
|
|
174
|
+
return candidates.join(' ');
|
|
50
175
|
}
|
|
176
|
+
|
|
177
|
+
private freshHeading(html: string, previousHtml: string): string | null {
|
|
178
|
+
const fresh = this.headingsOf(html).filter((heading) => !previousHtml.includes(heading));
|
|
179
|
+
return fresh[0] ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private headingsOf(html: string): string[] {
|
|
183
|
+
const headings = extractHeadings(html);
|
|
184
|
+
return [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean) as string[];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const SUBROOT_MIN_HTML = 5_000;
|
|
189
|
+
const SOFT_NAVIGATION_SIMILARITY = 50;
|
|
190
|
+
const REGION_MAX_RATIO = 0.6;
|
|
191
|
+
const REGION_DOMINANCE = 0.7;
|
|
192
|
+
|
|
193
|
+
// Serialized via toString() into page.evaluate — must stay a plain function with no outer-scope references.
|
|
194
|
+
function inspectRegion(config: { xpath: string }): RegionProbe {
|
|
195
|
+
const probe: RegionProbe = { found: false, onScreen: false, floating: false, centerBelongs: false, coveredByFloating: false };
|
|
196
|
+
|
|
197
|
+
const isFloating = (start: HTMLElement | null): boolean => {
|
|
198
|
+
let node = start;
|
|
199
|
+
while (node && node !== document.body) {
|
|
200
|
+
const style = window.getComputedStyle(node);
|
|
201
|
+
if (style.position === 'fixed' || style.position === 'absolute' || (Number.parseInt(style.zIndex || '0', 10) || 0) > 0) return true;
|
|
202
|
+
node = node.parentElement;
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const result = document.evaluate(config.xpath, document, null, 9, null);
|
|
208
|
+
const node = result.singleNodeValue;
|
|
209
|
+
if (!node || node.nodeType !== 1) return probe;
|
|
210
|
+
const element = node as HTMLElement;
|
|
211
|
+
const rect = element.getBoundingClientRect();
|
|
212
|
+
if (rect.width === 0 && rect.height === 0) return probe;
|
|
213
|
+
probe.found = true;
|
|
214
|
+
|
|
215
|
+
probe.floating = isFloating(element);
|
|
216
|
+
|
|
217
|
+
const left = Math.max(rect.left, 0);
|
|
218
|
+
const top = Math.max(rect.top, 0);
|
|
219
|
+
const right = Math.min(rect.right, window.innerWidth);
|
|
220
|
+
const bottom = Math.min(rect.bottom, window.innerHeight);
|
|
221
|
+
if (right <= left || bottom <= top) return probe;
|
|
222
|
+
probe.onScreen = true;
|
|
223
|
+
|
|
224
|
+
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
|
225
|
+
probe.centerBelongs = !!hit && (hit === element || element.contains(hit));
|
|
226
|
+
if (hit && !probe.centerBelongs) probe.coveredByFloating = isFloating(hit as HTMLElement);
|
|
227
|
+
return probe;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface RegionDiff {
|
|
231
|
+
parts: HtmlDiffPart[];
|
|
232
|
+
pageSize: number;
|
|
233
|
+
similarity: number;
|
|
234
|
+
sameUrl: boolean;
|
|
235
|
+
previousHtml: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface AppearedSubRoot {
|
|
239
|
+
container: string;
|
|
240
|
+
elementXPath: string;
|
|
241
|
+
subtree: string;
|
|
242
|
+
size: number;
|
|
243
|
+
rawSize: number;
|
|
244
|
+
appearedSelector?: string;
|
|
245
|
+
fresh: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface RegionProbe {
|
|
249
|
+
found: boolean;
|
|
250
|
+
onScreen: boolean;
|
|
251
|
+
floating: boolean;
|
|
252
|
+
centerBelongs: boolean;
|
|
253
|
+
coveredByFloating: boolean;
|
|
51
254
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RequestResult } from '../api/request-result.ts';
|
|
2
|
+
|
|
3
|
+
export class RequestMap {
|
|
4
|
+
private requests = new Map<string, RequestResult>();
|
|
5
|
+
|
|
6
|
+
constructor(requests: RequestResult[] = []) {
|
|
7
|
+
for (const request of requests) this.add(request);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
add(request: RequestResult): void {
|
|
11
|
+
const { id } = request.extractIdAndTitle();
|
|
12
|
+
if (id === undefined) return;
|
|
13
|
+
this.requests.set(String(id), request);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get(id: string | number): RequestResult | undefined {
|
|
17
|
+
return this.requests.get(String(id));
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/utils/url-matcher.ts
CHANGED
|
@@ -9,14 +9,15 @@ export function isDynamicSegment(segment: string): boolean {
|
|
|
9
9
|
/* config not loaded yet */
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
if (/^v\d+$/i.test(segment)) return false;
|
|
12
13
|
// numeric: /users/123
|
|
13
14
|
if (/^\d+$/.test(segment)) return true;
|
|
14
15
|
// UUID: /items/550e8400-e29b-41d4-a716-446655440000
|
|
15
16
|
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment)) return true;
|
|
16
17
|
// ULID: /items/01ARZ3NDEKTSV4RRFFQ69G5FAV
|
|
17
18
|
if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(segment)) return true;
|
|
18
|
-
// hex ID (4+ chars): /suite/70dae98a
|
|
19
|
-
if (/^[a-f0-9]{4,}$/i.test(segment)) return true;
|
|
19
|
+
// hex ID (4+ chars): /suite/70dae98a — short letters-only hex reads as a word
|
|
20
|
+
if (/^[a-f0-9]{4,}$/i.test(segment) && (segment.length >= 8 || /\d/.test(segment))) return true;
|
|
20
21
|
// hex-prefixed slug (8+ hex before dash): /suite/95ef0c94-mobile
|
|
21
22
|
if (/^[a-f0-9]{8,}-/i.test(segment)) return true;
|
|
22
23
|
// short mixed alphanumeric (digits + letters, ≤8 chars, no dash): /item/x7f2
|