explorbot 0.3.5 → 0.4.1

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 (155) hide show
  1. package/bin/explorbot-cli.ts +23 -16
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler.ts +7 -1
  4. package/boat/api-tester/src/apibot.ts +10 -4
  5. package/boat/api-tester/src/cli.ts +12 -2
  6. package/boat/api-tester/src/config.ts +28 -8
  7. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  8. package/boat/doc-collector/src/cli.ts +6 -1
  9. package/boat/doc-collector/src/docbot.ts +4 -2
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +23 -8
  12. package/boat/prima/src/envelope.ts +35 -9
  13. package/boat/prima/src/prima.ts +23 -10
  14. package/dist/bin/explorbot-cli.js +24 -16
  15. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  16. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  17. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  18. package/dist/boat/api-tester/src/apibot.js +7 -3
  19. package/dist/boat/api-tester/src/cli.js +12 -2
  20. package/dist/boat/api-tester/src/config.js +31 -8
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  22. package/dist/boat/doc-collector/src/cli.js +6 -1
  23. package/dist/boat/doc-collector/src/docbot.js +4 -2
  24. package/dist/boat/prima/bin/prima-cli.js +2 -0
  25. package/dist/boat/prima/src/cli.js +22 -8
  26. package/dist/boat/prima/src/envelope.js +24 -6
  27. package/dist/boat/prima/src/prima.js +23 -11
  28. package/dist/package.json +2 -2
  29. package/dist/rules/planner/styles/normal.md +1 -1
  30. package/dist/src/action-result.d.ts +9 -1
  31. package/dist/src/action-result.js +57 -18
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +87 -12
  34. package/dist/src/ai/captain.js +1 -1
  35. package/dist/src/ai/driller.d.ts +0 -1
  36. package/dist/src/ai/driller.js +8 -20
  37. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  38. package/dist/src/ai/fisherman-tools.js +52 -6
  39. package/dist/src/ai/fisherman.d.ts +4 -2
  40. package/dist/src/ai/fisherman.js +48 -27
  41. package/dist/src/ai/historian/codeceptjs.js +1 -1
  42. package/dist/src/ai/historian/playwright.js +1 -1
  43. package/dist/src/ai/navigator.js +1 -1
  44. package/dist/src/ai/pilot.d.ts +1 -0
  45. package/dist/src/ai/pilot.js +13 -1
  46. package/dist/src/ai/planner.js +9 -7
  47. package/dist/src/ai/provider.js +20 -3
  48. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  49. package/dist/src/ai/researcher.js +5 -3
  50. package/dist/src/ai/rules.js +3 -3
  51. package/dist/src/ai/tester.d.ts +3 -0
  52. package/dist/src/ai/tester.js +40 -3
  53. package/dist/src/ai/tools.d.ts +1 -0
  54. package/dist/src/ai/tools.js +13 -6
  55. package/dist/src/api/request-result.d.ts +2 -0
  56. package/dist/src/api/request-result.js +8 -2
  57. package/dist/src/api/request-store.d.ts +3 -2
  58. package/dist/src/api/request-store.js +66 -14
  59. package/dist/src/api/spec-reader.js +1 -1
  60. package/dist/src/commands/config-command.js +1 -1
  61. package/dist/src/commands/drill-command.js +1 -1
  62. package/dist/src/commands/explore-command.d.ts +6 -0
  63. package/dist/src/commands/explore-command.js +39 -3
  64. package/dist/src/commands/freesail-command.js +10 -1
  65. package/dist/src/commands/options/base-option.d.ts +8 -0
  66. package/dist/src/commands/options/base-option.js +12 -0
  67. package/dist/src/commands/options/index.d.ts +5 -0
  68. package/dist/src/commands/options/index.js +5 -0
  69. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  70. package/dist/src/commands/options/knowledge-option.js +12 -0
  71. package/dist/src/commands/options/ws-option.d.ts +7 -0
  72. package/dist/src/commands/options/ws-option.js +21 -0
  73. package/dist/src/commands/plans-command.js +6 -6
  74. package/dist/src/config.d.ts +1 -0
  75. package/dist/src/config.js +12 -0
  76. package/dist/src/experience-tracker.js +5 -0
  77. package/dist/src/explorbot.d.ts +0 -1
  78. package/dist/src/explorbot.js +24 -37
  79. package/dist/src/knowledge-tracker.d.ts +20 -7
  80. package/dist/src/knowledge-tracker.js +69 -31
  81. package/dist/src/remote.d.ts +0 -3
  82. package/dist/src/remote.js +0 -18
  83. package/dist/src/state-manager.d.ts +5 -1
  84. package/dist/src/state-manager.js +10 -7
  85. package/dist/src/test-plan.d.ts +3 -0
  86. package/dist/src/test-plan.js +27 -0
  87. package/dist/src/utils/aria.d.ts +1 -1
  88. package/dist/src/utils/aria.js +6 -42
  89. package/dist/src/utils/html-diff.d.ts +4 -0
  90. package/dist/src/utils/html-diff.js +62 -7
  91. package/dist/src/utils/html.d.ts +5 -15
  92. package/dist/src/utils/html.js +14 -85
  93. package/dist/src/utils/overlay.d.ts +56 -11
  94. package/dist/src/utils/overlay.js +191 -21
  95. package/dist/src/utils/request-map.d.ts +7 -0
  96. package/dist/src/utils/request-map.js +16 -0
  97. package/dist/src/utils/url-matcher.js +4 -2
  98. package/docs/api-testing/basics.md +15 -0
  99. package/docs/api-testing/planning.md +10 -1
  100. package/docs/reference/commands.md +32 -5
  101. package/docs/reference/websocket.md +1 -0
  102. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  103. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  104. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  105. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  106. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  107. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  108. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  109. package/docs/workflow/agentic-usage.md +12 -2
  110. package/docs/workflow/ci.md +1 -0
  111. package/docs/workflow/knowledge.md +46 -2
  112. package/package.json +2 -2
  113. package/rules/planner/styles/normal.md +1 -1
  114. package/src/action-result.ts +61 -22
  115. package/src/action.ts +87 -14
  116. package/src/ai/captain.ts +1 -1
  117. package/src/ai/driller.ts +7 -39
  118. package/src/ai/fisherman-tools.ts +56 -7
  119. package/src/ai/fisherman.ts +48 -28
  120. package/src/ai/historian/codeceptjs.ts +1 -1
  121. package/src/ai/historian/playwright.ts +1 -1
  122. package/src/ai/navigator.ts +1 -1
  123. package/src/ai/pilot.ts +9 -1
  124. package/src/ai/planner.ts +9 -8
  125. package/src/ai/provider.ts +21 -3
  126. package/src/ai/researcher/deep-analysis.ts +1 -2
  127. package/src/ai/researcher.ts +4 -3
  128. package/src/ai/rules.ts +3 -3
  129. package/src/ai/tester.ts +40 -3
  130. package/src/ai/tools.ts +17 -9
  131. package/src/api/request-result.ts +10 -2
  132. package/src/api/request-store.ts +60 -13
  133. package/src/api/spec-reader.ts +1 -1
  134. package/src/commands/config-command.ts +1 -1
  135. package/src/commands/drill-command.ts +1 -1
  136. package/src/commands/explore-command.ts +37 -3
  137. package/src/commands/freesail-command.ts +7 -1
  138. package/src/commands/options/base-option.ts +18 -0
  139. package/src/commands/options/index.ts +7 -0
  140. package/src/commands/options/knowledge-option.ts +14 -0
  141. package/src/commands/options/ws-option.ts +24 -0
  142. package/src/commands/plans-command.ts +6 -6
  143. package/src/config.ts +12 -0
  144. package/src/experience-tracker.ts +5 -1
  145. package/src/explorbot.ts +21 -37
  146. package/src/knowledge-tracker.ts +94 -36
  147. package/src/remote.ts +0 -20
  148. package/src/state-manager.ts +13 -7
  149. package/src/test-plan.ts +29 -0
  150. package/src/utils/aria.ts +7 -44
  151. package/src/utils/html-diff.ts +62 -7
  152. package/src/utils/html.ts +14 -91
  153. package/src/utils/overlay.ts +226 -23
  154. package/src/utils/request-map.ts +19 -0
  155. package/src/utils/url-matcher.ts +3 -2
package/src/utils/html.ts CHANGED
@@ -101,17 +101,12 @@ export const HTML_SELECTORS = {
101
101
  } as const;
102
102
 
103
103
  export const HTML_VISIBILITY_LIMITS = {
104
- maxViewportOverlayRatio: 0.95,
105
104
  minOpacity: 0.1,
106
- minOverlayHeight: 40,
107
- minOverlayWidth: 80,
108
105
  } as const;
109
106
 
110
107
  export const HTML_EXTRACTION_LIMITS = {
111
108
  componentScopeHtmlLength: 8000,
112
- maxOverlayCount: 3,
113
109
  maxScopeInteractiveCount: 16,
114
- overlayHtmlLength: 6000,
115
110
  } as const;
116
111
 
117
112
  export const CODE_EDITOR_MARKERS = ['monaco', 'codemirror', 'ace', 'ace_editor', 'code'] as const;
@@ -158,14 +153,6 @@ export const ELEMENT_EXTRACTION_CONFIG = {
158
153
 
159
154
  export type ElementExtractionConfig = typeof ELEMENT_EXTRACTION_CONFIG;
160
155
  export type RawElementData = NonNullable<ReturnType<typeof extractElementData>>;
161
- export type VisibleOverlayExtractionConfig = {
162
- interactiveContentSelector: string;
163
- limits: typeof HTML_EXTRACTION_LIMITS;
164
- overlaySelectors: readonly string[];
165
- overlaySemanticSelector: string;
166
- visibilityLimits: typeof HTML_VISIBILITY_LIMITS;
167
- geometryFallback?: boolean;
168
- };
169
156
  export type ComponentScopeExtractionConfig = {
170
157
  eidxAttr: string;
171
158
  interactiveControlSelector: string;
@@ -449,80 +436,6 @@ export function getElementDataExtractorSource(): string {
449
436
  return extractElementData.toString();
450
437
  }
451
438
 
452
- export function extractVisibleOverlayHtml(config: VisibleOverlayExtractionConfig): string {
453
- function isVisible(element: Element): boolean {
454
- const html = element as HTMLElement;
455
- const style = window.getComputedStyle(html);
456
- const rect = html.getBoundingClientRect();
457
- if (rect.width === 0 && rect.height === 0) return false;
458
- if (style.display === 'none' || style.visibility === 'hidden') return false;
459
- if (Number.parseFloat(style.opacity || '1') < config.visibilityLimits.minOpacity) return false;
460
- return true;
461
- }
462
-
463
- function getUsefulContent(element: Element): { interactiveCount: number; text: string } {
464
- const text = (element.textContent || '').replace(/\s+/g, ' ').trim();
465
- const interactiveCount = element.querySelectorAll(config.interactiveContentSelector).length;
466
- return { interactiveCount, text };
467
- }
468
-
469
- function isLikelyFloatingOverlay(element: Element): boolean {
470
- const html = element as HTMLElement;
471
- const style = window.getComputedStyle(html);
472
- const rect = html.getBoundingClientRect();
473
- const zIndex = Number.parseInt(style.zIndex || '0', 10);
474
- const isFloating = style.position === 'fixed' || style.position === 'absolute' || style.position === 'sticky' || zIndex > 0;
475
- if (!isFloating) return false;
476
- if (rect.width < config.visibilityLimits.minOverlayWidth || rect.height < config.visibilityLimits.minOverlayHeight) return false;
477
- if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth) return false;
478
- if (rect.width >= window.innerWidth * config.visibilityLimits.maxViewportOverlayRatio && rect.height >= window.innerHeight * config.visibilityLimits.maxViewportOverlayRatio) return false;
479
- const { interactiveCount, text } = getUsefulContent(element);
480
- return interactiveCount > 0 || text.length > 0;
481
- }
482
-
483
- function isFloatingOverlay(element: Element): boolean {
484
- const style = window.getComputedStyle(element as HTMLElement);
485
- return style.position === 'fixed' || style.position === 'absolute' || Number.parseInt(style.zIndex || '0', 10) > 0;
486
- }
487
-
488
- const seen = new Set<Element>();
489
- const collected: Element[] = [];
490
- for (const selector of config.overlaySelectors) {
491
- for (const element of Array.from(document.querySelectorAll(selector))) {
492
- if (seen.has(element)) continue;
493
- seen.add(element);
494
- if (!isVisible(element)) continue;
495
- if (!element.matches(config.overlaySemanticSelector) && !isFloatingOverlay(element)) continue;
496
- const { interactiveCount, text } = getUsefulContent(element);
497
- if (interactiveCount === 0 && text.length === 0) continue;
498
- collected.push(element);
499
- }
500
- }
501
-
502
- const overlays = collected.filter((element) => !collected.some((other) => other !== element && element.contains(other))).map((element) => (element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
503
-
504
- if (overlays.length === 0 && config.geometryFallback !== false) {
505
- const floatingCandidates = Array.from(document.body.querySelectorAll('*'))
506
- .filter((element) => !seen.has(element) && isVisible(element) && isLikelyFloatingOverlay(element))
507
- .sort((left, right) => {
508
- const leftStyle = window.getComputedStyle(left as HTMLElement);
509
- const rightStyle = window.getComputedStyle(right as HTMLElement);
510
- const leftZ = Number.parseInt(leftStyle.zIndex || '0', 10) || 0;
511
- const rightZ = Number.parseInt(rightStyle.zIndex || '0', 10) || 0;
512
- if (leftZ !== rightZ) return rightZ - leftZ;
513
- const leftRect = (left as HTMLElement).getBoundingClientRect();
514
- const rightRect = (right as HTMLElement).getBoundingClientRect();
515
- return leftRect.width * leftRect.height - rightRect.width * rightRect.height;
516
- });
517
-
518
- for (const element of floatingCandidates.slice(0, config.limits.maxOverlayCount)) {
519
- overlays.push((element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
520
- }
521
- }
522
-
523
- return overlays.slice(0, config.limits.maxOverlayCount).join('\n\n--- overlay ---\n\n');
524
- }
525
-
526
439
  export function extractComponentScopeHtml(eidx: string, config: ComponentScopeExtractionConfig): string {
527
440
  const element = document.querySelector(`[${config.eidxAttr}="${eidx}"]`);
528
441
  if (!element) return '';
@@ -544,10 +457,6 @@ export function extractComponentScopeHtml(eidx: string, config: ComponentScopeEx
544
457
  return '';
545
458
  }
546
459
 
547
- export function getVisibleOverlayHtmlExtractorSource(): string {
548
- return extractVisibleOverlayHtml.toString();
549
- }
550
-
551
460
  export function getComponentScopeHtmlExtractorSource(): string {
552
461
  return extractComponentScopeHtml.toString();
553
462
  }
@@ -1020,6 +929,19 @@ export function htmlCombinedSnapshot(html: string, htmlConfig?: HtmlConfig['comb
1020
929
  return serialize(document);
1021
930
  }
1022
931
 
932
+ /**
933
+ * Cleans a small HTML snippet for AI consumption: keeps structure and text,
934
+ * drops noisy attributes and generated class names
935
+ */
936
+ export function cleanHtmlSnippet(html: string): string {
937
+ const fragment = parseFragment(html);
938
+ for (const node of fragment.childNodes) {
939
+ if (!('tagName' in node)) continue;
940
+ cleanAllElements(node as parse5TreeAdapter.Element);
941
+ }
942
+ return serialize(fragment);
943
+ }
944
+
1023
945
  /**
1024
946
  * Creates text-only snapshot with markdown formatting
1025
947
  */
@@ -1526,6 +1448,7 @@ function cleanElement(element: parse5TreeAdapter.Element): void {
1526
1448
  if (attr.name === 'class') {
1527
1449
  attr.value = attr.value
1528
1450
  .split(/\s+/)
1451
+ .filter(Boolean)
1529
1452
  .filter((className) => !/\d/.test(className))
1530
1453
  .filter((className) => !className.includes(':'))
1531
1454
  .filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
@@ -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