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.
Files changed (102) hide show
  1. package/bin/explorbot-cli.ts +18 -13
  2. package/boat/doc-collector/src/cli.ts +3 -0
  3. package/boat/doc-collector/src/docbot.ts +3 -1
  4. package/boat/prima/src/cli.ts +21 -8
  5. package/boat/prima/src/envelope.ts +35 -9
  6. package/boat/prima/src/prima.ts +23 -10
  7. package/dist/bin/explorbot-cli.js +19 -13
  8. package/dist/boat/doc-collector/src/cli.js +3 -0
  9. package/dist/boat/doc-collector/src/docbot.js +3 -1
  10. package/dist/boat/prima/src/cli.js +19 -8
  11. package/dist/boat/prima/src/envelope.js +24 -6
  12. package/dist/boat/prima/src/prima.js +23 -11
  13. package/dist/package.json +2 -2
  14. package/dist/src/action-result.d.ts +9 -1
  15. package/dist/src/action-result.js +57 -18
  16. package/dist/src/action.d.ts +1 -1
  17. package/dist/src/action.js +87 -12
  18. package/dist/src/ai/driller.d.ts +0 -1
  19. package/dist/src/ai/driller.js +8 -20
  20. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  21. package/dist/src/ai/fisherman-tools.js +52 -6
  22. package/dist/src/ai/fisherman.d.ts +4 -2
  23. package/dist/src/ai/fisherman.js +48 -27
  24. package/dist/src/ai/historian/codeceptjs.js +1 -1
  25. package/dist/src/ai/historian/playwright.js +1 -1
  26. package/dist/src/ai/pilot.d.ts +1 -0
  27. package/dist/src/ai/pilot.js +13 -1
  28. package/dist/src/ai/provider.js +20 -3
  29. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  30. package/dist/src/ai/researcher.js +3 -3
  31. package/dist/src/ai/tester.d.ts +3 -0
  32. package/dist/src/ai/tester.js +40 -3
  33. package/dist/src/ai/tools.d.ts +1 -0
  34. package/dist/src/ai/tools.js +13 -6
  35. package/dist/src/api/request-result.d.ts +2 -0
  36. package/dist/src/api/request-result.js +8 -2
  37. package/dist/src/api/request-store.d.ts +3 -2
  38. package/dist/src/api/request-store.js +66 -14
  39. package/dist/src/commands/explore-command.d.ts +6 -0
  40. package/dist/src/commands/explore-command.js +27 -2
  41. package/dist/src/commands/freesail-command.js +10 -1
  42. package/dist/src/commands/plans-command.js +6 -6
  43. package/dist/src/config.js +1 -0
  44. package/dist/src/experience-tracker.js +5 -0
  45. package/dist/src/explorbot.d.ts +0 -1
  46. package/dist/src/explorbot.js +23 -36
  47. package/dist/src/state-manager.d.ts +5 -1
  48. package/dist/src/state-manager.js +10 -7
  49. package/dist/src/test-plan.d.ts +3 -0
  50. package/dist/src/test-plan.js +27 -0
  51. package/dist/src/utils/aria.d.ts +1 -1
  52. package/dist/src/utils/aria.js +6 -42
  53. package/dist/src/utils/html-diff.d.ts +4 -0
  54. package/dist/src/utils/html-diff.js +62 -7
  55. package/dist/src/utils/html.d.ts +5 -15
  56. package/dist/src/utils/html.js +14 -85
  57. package/dist/src/utils/overlay.d.ts +56 -11
  58. package/dist/src/utils/overlay.js +191 -21
  59. package/dist/src/utils/request-map.d.ts +7 -0
  60. package/dist/src/utils/request-map.js +16 -0
  61. package/dist/src/utils/url-matcher.js +4 -2
  62. package/docs/reference/commands.md +8 -1
  63. package/docs/reference/websocket.md +1 -0
  64. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  65. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  66. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  67. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  68. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  69. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  70. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  71. package/docs/workflow/agentic-usage.md +1 -0
  72. package/docs/workflow/ci.md +1 -0
  73. package/package.json +2 -2
  74. package/src/action-result.ts +61 -22
  75. package/src/action.ts +87 -14
  76. package/src/ai/driller.ts +7 -39
  77. package/src/ai/fisherman-tools.ts +56 -7
  78. package/src/ai/fisherman.ts +48 -28
  79. package/src/ai/historian/codeceptjs.ts +1 -1
  80. package/src/ai/historian/playwright.ts +1 -1
  81. package/src/ai/pilot.ts +9 -1
  82. package/src/ai/provider.ts +21 -3
  83. package/src/ai/researcher/deep-analysis.ts +1 -2
  84. package/src/ai/researcher.ts +3 -3
  85. package/src/ai/tester.ts +40 -3
  86. package/src/ai/tools.ts +17 -9
  87. package/src/api/request-result.ts +10 -2
  88. package/src/api/request-store.ts +60 -13
  89. package/src/commands/explore-command.ts +25 -2
  90. package/src/commands/freesail-command.ts +7 -1
  91. package/src/commands/plans-command.ts +6 -6
  92. package/src/config.ts +1 -0
  93. package/src/experience-tracker.ts +5 -1
  94. package/src/explorbot.ts +20 -36
  95. package/src/state-manager.ts +13 -7
  96. package/src/test-plan.ts +29 -0
  97. package/src/utils/aria.ts +7 -44
  98. package/src/utils/html-diff.ts +62 -7
  99. package/src/utils/html.ts +14 -91
  100. package/src/utils/overlay.ts +226 -23
  101. package/src/utils/request-map.ts +19 -0
  102. package/src/utils/url-matcher.ts +3 -2
@@ -1,51 +1,254 @@
1
1
  import { detectFocusArea } from './aria.js';
2
- import { HTML_EXTRACTION_LIMITS, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, type VisibleOverlayExtractionConfig, extractHeadings } from './html.js';
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
- export const OVERLAY_SELECTORS = {
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 OverlayData = { type?: 'dialog' | 'modal' | null; name?: string | null };
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: 'dialog' | 'modal' | null;
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
- static fromHtml(html: string): Overlay {
26
- const headings = extractHeadings(html);
27
- const name = [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean).join(' ');
28
- return new Overlay({ type: 'modal', name: name || null });
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: { overlayHtml?: string; overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay {
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
- static captureConfig(): VisibleOverlayExtractionConfig {
42
- return {
43
- interactiveContentSelector: HTML_SELECTORS.interactiveContent,
44
- limits: HTML_EXTRACTION_LIMITS,
45
- overlaySelectors: OVERLAY_SELECTORS.modalOverlays,
46
- overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
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
+ }
@@ -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