@silbercue/chrome 0.2.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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/build/cache/a11y-tree.d.ts +252 -0
  4. package/build/cache/a11y-tree.js +1956 -0
  5. package/build/cache/index.d.ts +8 -0
  6. package/build/cache/index.js +4 -0
  7. package/build/cache/selector-cache.d.ts +47 -0
  8. package/build/cache/selector-cache.js +119 -0
  9. package/build/cache/session-defaults.d.ts +27 -0
  10. package/build/cache/session-defaults.js +130 -0
  11. package/build/cache/tab-state-cache.d.ts +39 -0
  12. package/build/cache/tab-state-cache.js +171 -0
  13. package/build/cdp/cdp-client.d.ts +25 -0
  14. package/build/cdp/cdp-client.js +146 -0
  15. package/build/cdp/chrome-launcher.d.ts +85 -0
  16. package/build/cdp/chrome-launcher.js +502 -0
  17. package/build/cdp/console-collector.d.ts +53 -0
  18. package/build/cdp/console-collector.js +147 -0
  19. package/build/cdp/debug.d.ts +1 -0
  20. package/build/cdp/debug.js +6 -0
  21. package/build/cdp/dialog-handler.d.ts +54 -0
  22. package/build/cdp/dialog-handler.js +129 -0
  23. package/build/cdp/dom-watcher.d.ts +45 -0
  24. package/build/cdp/dom-watcher.js +195 -0
  25. package/build/cdp/emulation.d.ts +12 -0
  26. package/build/cdp/emulation.js +17 -0
  27. package/build/cdp/index.d.ts +11 -0
  28. package/build/cdp/index.js +6 -0
  29. package/build/cdp/network-collector.d.ts +77 -0
  30. package/build/cdp/network-collector.js +257 -0
  31. package/build/cdp/protocol.d.ts +20 -0
  32. package/build/cdp/protocol.js +1 -0
  33. package/build/cdp/session-manager.d.ts +62 -0
  34. package/build/cdp/session-manager.js +205 -0
  35. package/build/cdp/settle.d.ts +16 -0
  36. package/build/cdp/settle.js +71 -0
  37. package/build/cli/license-commands.d.ts +19 -0
  38. package/build/cli/license-commands.js +199 -0
  39. package/build/cli/top-level-commands.d.ts +49 -0
  40. package/build/cli/top-level-commands.js +222 -0
  41. package/build/hooks/index.d.ts +2 -0
  42. package/build/hooks/index.js +1 -0
  43. package/build/hooks/pro-hooks.d.ts +126 -0
  44. package/build/hooks/pro-hooks.js +17 -0
  45. package/build/index.d.ts +4 -0
  46. package/build/index.js +86 -0
  47. package/build/license/free-tier-config.d.ts +14 -0
  48. package/build/license/free-tier-config.js +18 -0
  49. package/build/license/index.d.ts +4 -0
  50. package/build/license/index.js +2 -0
  51. package/build/license/license-status.d.ts +15 -0
  52. package/build/license/license-status.js +9 -0
  53. package/build/overlay/session-overlay.d.ts +22 -0
  54. package/build/overlay/session-overlay.js +372 -0
  55. package/build/plan/index.d.ts +7 -0
  56. package/build/plan/index.js +4 -0
  57. package/build/plan/plan-conditions.d.ts +12 -0
  58. package/build/plan/plan-conditions.js +242 -0
  59. package/build/plan/plan-executor.d.ts +49 -0
  60. package/build/plan/plan-executor.js +259 -0
  61. package/build/plan/plan-state-store.d.ts +24 -0
  62. package/build/plan/plan-state-store.js +43 -0
  63. package/build/plan/plan-variables.d.ts +16 -0
  64. package/build/plan/plan-variables.js +71 -0
  65. package/build/registry.d.ts +124 -0
  66. package/build/registry.js +884 -0
  67. package/build/server.d.ts +1 -0
  68. package/build/server.js +245 -0
  69. package/build/tools/click.d.ts +34 -0
  70. package/build/tools/click.js +293 -0
  71. package/build/tools/configure-session.d.ts +15 -0
  72. package/build/tools/configure-session.js +45 -0
  73. package/build/tools/console-logs.d.ts +18 -0
  74. package/build/tools/console-logs.js +44 -0
  75. package/build/tools/dom-snapshot.d.ts +13 -0
  76. package/build/tools/dom-snapshot.js +259 -0
  77. package/build/tools/element-utils.d.ts +23 -0
  78. package/build/tools/element-utils.js +133 -0
  79. package/build/tools/error-utils.d.ts +8 -0
  80. package/build/tools/error-utils.js +27 -0
  81. package/build/tools/evaluate.d.ts +34 -0
  82. package/build/tools/evaluate.js +217 -0
  83. package/build/tools/file-upload.d.ts +20 -0
  84. package/build/tools/file-upload.js +174 -0
  85. package/build/tools/fill-form.d.ts +39 -0
  86. package/build/tools/fill-form.js +256 -0
  87. package/build/tools/handle-dialog.d.ts +15 -0
  88. package/build/tools/handle-dialog.js +48 -0
  89. package/build/tools/index.d.ts +35 -0
  90. package/build/tools/index.js +18 -0
  91. package/build/tools/navigate.d.ts +18 -0
  92. package/build/tools/navigate.js +111 -0
  93. package/build/tools/network-monitor.d.ts +18 -0
  94. package/build/tools/network-monitor.js +66 -0
  95. package/build/tools/observe.d.ts +44 -0
  96. package/build/tools/observe.js +339 -0
  97. package/build/tools/press-key.d.ts +33 -0
  98. package/build/tools/press-key.js +155 -0
  99. package/build/tools/read-page.d.ts +22 -0
  100. package/build/tools/read-page.js +100 -0
  101. package/build/tools/run-plan.d.ts +205 -0
  102. package/build/tools/run-plan.js +215 -0
  103. package/build/tools/screenshot.d.ts +16 -0
  104. package/build/tools/screenshot.js +283 -0
  105. package/build/tools/scroll.d.ts +28 -0
  106. package/build/tools/scroll.js +143 -0
  107. package/build/tools/switch-tab.d.ts +26 -0
  108. package/build/tools/switch-tab.js +355 -0
  109. package/build/tools/tab-status.d.ts +7 -0
  110. package/build/tools/tab-status.js +50 -0
  111. package/build/tools/type.d.ts +31 -0
  112. package/build/tools/type.js +247 -0
  113. package/build/tools/virtual-desk.d.ts +7 -0
  114. package/build/tools/virtual-desk.js +108 -0
  115. package/build/tools/visual-constants.d.ts +3 -0
  116. package/build/tools/visual-constants.js +10 -0
  117. package/build/tools/wait-for.d.ts +26 -0
  118. package/build/tools/wait-for.js +323 -0
  119. package/build/transport/index.d.ts +3 -0
  120. package/build/transport/index.js +2 -0
  121. package/build/transport/pipe-transport.d.ts +18 -0
  122. package/build/transport/pipe-transport.js +63 -0
  123. package/build/transport/transport.d.ts +8 -0
  124. package/build/transport/transport.js +1 -0
  125. package/build/transport/websocket-transport.d.ts +22 -0
  126. package/build/transport/websocket-transport.js +200 -0
  127. package/build/types.d.ts +21 -0
  128. package/build/types.js +1 -0
  129. package/package.json +62 -0
@@ -0,0 +1,1956 @@
1
+ import { wrapCdpError } from "../tools/error-utils.js";
2
+ import { CLICKABLE_TAGS, CLICKABLE_ROLES, COMPUTED_STYLES } from "../tools/visual-constants.js";
3
+ import { EMULATED_WIDTH, EMULATED_HEIGHT } from "../cdp/emulation.js";
4
+ import { debug } from "../cdp/debug.js";
5
+ /** Strip hash fragment from URL for navigation comparison.
6
+ * Hash-only changes (anchor navigation) should NOT reset refs. */
7
+ function stripHash(url) {
8
+ const idx = url.indexOf("#");
9
+ return idx === -1 ? url : url.slice(0, idx);
10
+ }
11
+ // --- Constants ---
12
+ /** BUG-009: Safety cap to prevent oversized responses that MCP clients truncate silently.
13
+ * ~200KB chars ≈ 50K tokens. Large enough for normal pages, prevents 855KB+ responses. */
14
+ const DEFAULT_MAX_TOKENS = 50_000;
15
+ const INTERACTIVE_ROLES = new Set([
16
+ "button",
17
+ "link",
18
+ "textbox",
19
+ "searchbox",
20
+ "combobox",
21
+ "checkbox",
22
+ "radio",
23
+ "switch",
24
+ "slider",
25
+ "spinbutton",
26
+ "tab",
27
+ "menuitem",
28
+ "menuitemcheckbox",
29
+ "menuitemradio",
30
+ "option",
31
+ "treeitem",
32
+ ]);
33
+ // Story 13a.2: Roles included in enriched compact snapshot for LLM orientation
34
+ const CONTEXT_ROLES = new Set([
35
+ "heading",
36
+ "alert",
37
+ "status",
38
+ ]);
39
+ const CONTAINER_ROLES = new Set([
40
+ "generic", "group", "region", "list", "listbox",
41
+ "navigation", "complementary", "main", "banner",
42
+ "contentinfo", "form", "search", "toolbar", "tablist",
43
+ "menu", "menubar", "tree", "grid", "table",
44
+ "rowgroup", "row", "treegrid",
45
+ ]);
46
+ const CONTENT_ROLES = new Set([
47
+ "heading", "paragraph", "text", "StaticText", "img",
48
+ "figure", "blockquote", "code", "listitem", "cell",
49
+ "columnheader", "rowheader", "caption", "definition",
50
+ "term", "note", "math", "status", "log", "marquee",
51
+ "timer", "alert",
52
+ ]);
53
+ function classifyElement(role) {
54
+ if (INTERACTIVE_ROLES.has(role))
55
+ return "interactive";
56
+ if (CONTENT_ROLES.has(role))
57
+ return "content";
58
+ return "container"; // Default: everything else is container
59
+ }
60
+ /** Token estimation for structured A11y output.
61
+ * Ratio ~3.5 chars/token accounts for short tokens (brackets, refs, keywords).
62
+ * FR-H8: Tighter ratio ensures max_tokens is a reliable upper bound. */
63
+ function estimateTokens(text) {
64
+ if (text.length === 0)
65
+ return 0;
66
+ return Math.ceil(text.length / 3.5);
67
+ }
68
+ const LANDMARK_ROLES = new Set([
69
+ "banner",
70
+ "navigation",
71
+ "main",
72
+ "complementary",
73
+ "contentinfo",
74
+ "search",
75
+ "form",
76
+ "region",
77
+ ]);
78
+ // Story 13a.2: Extract NodeInfo from AXNode including widget-state properties
79
+ function extractNodeInfo(node) {
80
+ const info = {
81
+ role: node.role?.value ?? "",
82
+ name: node.name?.value ?? "",
83
+ };
84
+ if (node.properties) {
85
+ for (const prop of node.properties) {
86
+ switch (prop.name) {
87
+ case "expanded":
88
+ info.expanded = prop.value.value;
89
+ break;
90
+ case "hasPopup":
91
+ info.hasPopup = prop.value.value;
92
+ break;
93
+ case "checked":
94
+ info.checked = prop.value.value;
95
+ break;
96
+ case "pressed":
97
+ info.pressed = prop.value.value;
98
+ break;
99
+ case "disabled":
100
+ info.disabled = prop.value.value;
101
+ break;
102
+ case "focusable":
103
+ info.focusable = prop.value.value;
104
+ break;
105
+ case "level":
106
+ info.level = prop.value.value;
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ return info;
112
+ }
113
+ export class A11yTreeProcessor {
114
+ refMap = new Map(); // backendDOMNodeId → refNumber
115
+ reverseMap = new Map(); // refNumber → backendDOMNodeId
116
+ // Story 13a.2: Extended with widget-state props for pre-click classification
117
+ nodeInfoMap = new Map(); // backendDOMNodeId → NodeInfo
118
+ sessionNodeMap = new Map(); // sessionId → Set<backendDOMNodeId>
119
+ nextRef = 1;
120
+ lastUrl = "";
121
+ // Precomputed cache state (Story 7.4)
122
+ _precomputedNodes = null;
123
+ _precomputedUrl = "";
124
+ _precomputedSessionId = "";
125
+ _precomputedDepth = 3;
126
+ // Story 13.1: Ambient Page Context — cache version counter
127
+ // Increments on every cache change (refresh, reset, invalidation).
128
+ // Registry compares this against _lastSentVersion to decide whether to attach page context.
129
+ _cacheVersion = 0;
130
+ /** Story 13.1: Current cache version — increments on every state change */
131
+ get cacheVersion() {
132
+ return this._cacheVersion;
133
+ }
134
+ reset() {
135
+ this.refMap.clear();
136
+ this.reverseMap.clear();
137
+ this.nodeInfoMap.clear();
138
+ this.sessionNodeMap.clear();
139
+ this.nextRef = 1;
140
+ this.lastUrl = "";
141
+ this._cacheVersion++;
142
+ this.invalidatePrecomputed();
143
+ }
144
+ /** Invalidiert den Precomputed-Cache (z.B. nach Navigation oder Reconnect) */
145
+ invalidatePrecomputed() {
146
+ this._precomputedNodes = null;
147
+ this._precomputedUrl = "";
148
+ this._precomputedSessionId = "";
149
+ this._precomputedDepth = 3;
150
+ this._cacheVersion++;
151
+ }
152
+ /** Hintergrund-Refresh: Laedt A11y-Tree und speichert als Cache */
153
+ async refreshPrecomputed(cdpClient, sessionId, sessionManager) {
154
+ // 1. URL pruefen — wenn sich die Basis-URL (ohne Hash) geaendert hat, reset() aufrufen
155
+ // Hash-only-Aenderungen (z.B. /#step-alpha → /#step-beta) behalten Refs,
156
+ // da das DOM bei Anchor-Navigation identisch bleibt.
157
+ const urlResult = await cdpClient.send("Runtime.evaluate", { expression: "document.URL", returnByValue: true }, sessionId);
158
+ const currentUrl = urlResult.result.value;
159
+ if (stripHash(currentUrl) !== stripHash(this.lastUrl)) {
160
+ this.reset();
161
+ }
162
+ this.lastUrl = currentUrl;
163
+ // 2. A11y-Tree via CDP laden (same depth as default getTree)
164
+ const result = await cdpClient.send("Accessibility.getFullAXTree", { depth: 3 }, sessionId);
165
+ if (!result.nodes || result.nodes.length === 0)
166
+ return;
167
+ // 3. Ref-IDs zuweisen (STABIL — bestehende Refs bleiben, neue bekommen neue Nummern)
168
+ for (const node of result.nodes) {
169
+ if (node.ignored || node.backendDOMNodeId === undefined)
170
+ continue;
171
+ if (!this.refMap.has(node.backendDOMNodeId)) {
172
+ const refNum = this.nextRef++;
173
+ this.refMap.set(node.backendDOMNodeId, refNum);
174
+ this.reverseMap.set(refNum, node.backendDOMNodeId);
175
+ }
176
+ this.nodeInfoMap.set(node.backendDOMNodeId, extractNodeInfo(node));
177
+ if (!this.sessionNodeMap.has(sessionId)) {
178
+ this.sessionNodeMap.set(sessionId, new Set());
179
+ }
180
+ this.sessionNodeMap.get(sessionId).add(node.backendDOMNodeId);
181
+ sessionManager?.registerNode(node.backendDOMNodeId, sessionId);
182
+ }
183
+ // 4. Cache speichern
184
+ this._precomputedNodes = result.nodes;
185
+ this._precomputedUrl = currentUrl;
186
+ this._precomputedSessionId = sessionId;
187
+ this._precomputedDepth = 3;
188
+ this._cacheVersion++;
189
+ // 5. Register root node for Accessibility.nodesUpdated tracking (Story 13a.2 fix).
190
+ // getFullAXTree does NOT populate Chrome's nodes_requested_ set, so nodesUpdated
191
+ // never fires. A single getRootAXNode call registers the root — 1 extra CDP call.
192
+ try {
193
+ await cdpClient.send("Accessibility.getRootAXNode", {}, sessionId);
194
+ }
195
+ catch {
196
+ // Non-critical — nodesUpdated won't work but everything else still does
197
+ debug("A11yTreeProcessor: getRootAXNode failed (nodesUpdated tracking disabled)");
198
+ }
199
+ // 6. FR-004 + FR-005: Enrich nodes with HTML attributes and click listeners
200
+ await this._enrichNodeMetadata(cdpClient, sessionId);
201
+ // Phase 3: FR-001 — detect scrollable containers (1 CDP call total)
202
+ try {
203
+ const scrollResult = await cdpClient.send("Runtime.evaluate", {
204
+ expression: `JSON.stringify([...document.querySelectorAll('*')].filter(el => { const s = getComputedStyle(el); return (s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight; }).map(el => el.id).filter(Boolean))`,
205
+ returnByValue: true,
206
+ }, sessionId);
207
+ const scrollableIds = JSON.parse(scrollResult.result.value || "[]");
208
+ for (const id of scrollableIds) {
209
+ for (const [backendNodeId, info] of this.nodeInfoMap) {
210
+ if (info.htmlId === id) {
211
+ info.isScrollable = true;
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ }
217
+ catch {
218
+ // Non-critical — scrollable annotation is nice-to-have
219
+ }
220
+ debug("A11yTreeProcessor: precomputed cache refreshed, %d nodes cached (v%d)", result.nodes.length, this._cacheVersion);
221
+ }
222
+ /** Prueft ob ein gueltiger Precomputed-Cache vorliegt */
223
+ hasPrecomputed(sessionId) {
224
+ return this._precomputedNodes !== null
225
+ && this._precomputedSessionId === sessionId;
226
+ }
227
+ /**
228
+ * H1: Remove all node references for a detached OOPIF session.
229
+ * Called when an OOPIF frame navigates away or is destroyed.
230
+ */
231
+ removeNodesForSession(sessionId) {
232
+ const nodeIds = this.sessionNodeMap.get(sessionId);
233
+ if (!nodeIds)
234
+ return;
235
+ for (const backendNodeId of nodeIds) {
236
+ const refNum = this.refMap.get(backendNodeId);
237
+ if (refNum !== undefined) {
238
+ this.reverseMap.delete(refNum);
239
+ }
240
+ this.refMap.delete(backendNodeId);
241
+ this.nodeInfoMap.delete(backendNodeId);
242
+ }
243
+ this.sessionNodeMap.delete(sessionId);
244
+ }
245
+ resolveRef(ref) {
246
+ const match = ref.match(/^e(\d+)$/);
247
+ if (!match)
248
+ return undefined;
249
+ const refNum = parseInt(match[1], 10);
250
+ return this.reverseMap.get(refNum);
251
+ }
252
+ getNodeInfo(backendNodeId) {
253
+ return this.nodeInfoMap.get(backendNodeId);
254
+ }
255
+ /**
256
+ * UX-001: Find an element by visible text (name). Returns ref string and backendNodeId.
257
+ * Matching priority: exact → case-insensitive exact → partial substring.
258
+ * Within each tier, interactive roles (button, link, etc.) are preferred.
259
+ */
260
+ findByText(text) {
261
+ if (!text || this.reverseMap.size === 0)
262
+ return null;
263
+ const lower = text.toLowerCase();
264
+ const exact = [];
265
+ const iexact = [];
266
+ const partial = [];
267
+ for (const [refNum, backendNodeId] of this.reverseMap) {
268
+ const info = this.nodeInfoMap.get(backendNodeId);
269
+ if (!info || !info.name)
270
+ continue;
271
+ const interactive = INTERACTIVE_ROLES.has(info.role) || !!info.isClickable;
272
+ if (info.name === text) {
273
+ exact.push({ refNum, backendNodeId, interactive });
274
+ }
275
+ else if (info.name.toLowerCase() === lower) {
276
+ iexact.push({ refNum, backendNodeId, interactive });
277
+ }
278
+ else if (info.name.toLowerCase().includes(lower)) {
279
+ partial.push({ refNum, backendNodeId, interactive });
280
+ }
281
+ }
282
+ // Pick best candidate: prefer interactive, then lowest refNum (most stable)
283
+ const pick = (candidates) => {
284
+ const interactive = candidates.filter(c => c.interactive);
285
+ const best = (interactive.length > 0 ? interactive : candidates)
286
+ .sort((a, b) => a.refNum - b.refNum)[0];
287
+ return best ? { ref: `e${best.refNum}`, backendNodeId: best.backendNodeId } : null;
288
+ };
289
+ return pick(exact) ?? pick(iexact) ?? pick(partial);
290
+ }
291
+ /** Returns true if the ref map has been populated (i.e. getTree was called at least once). */
292
+ hasRefs() {
293
+ return this.refMap.size > 0;
294
+ }
295
+ /** Number of currently assigned refs (for DOM fingerprinting, Story 7.5) */
296
+ get refCount() {
297
+ return this.refMap.size;
298
+ }
299
+ /** Current page URL (for on-the-fly fingerprint computation, Story 7.5 H1 fix) */
300
+ get currentUrl() {
301
+ return this.lastUrl;
302
+ }
303
+ getRefForBackendNodeId(backendNodeId) {
304
+ const refNum = this.refMap.get(backendNodeId);
305
+ return refNum !== undefined ? `e${refNum}` : undefined;
306
+ }
307
+ async fetchVisualData(cdpClient, sessionId) {
308
+ const snapshot = await cdpClient.send("DOMSnapshot.captureSnapshot", {
309
+ computedStyles: [...COMPUTED_STYLES],
310
+ includeDOMRects: true,
311
+ includeBlendedBackgroundColors: true,
312
+ includePaintOrder: true,
313
+ }, sessionId);
314
+ const visualMap = new Map();
315
+ if (!snapshot.documents || snapshot.documents.length === 0) {
316
+ return visualMap;
317
+ }
318
+ const doc = snapshot.documents[0];
319
+ const strings = snapshot.strings;
320
+ // Build layout index map: nodeIndex → layoutIndex
321
+ const layoutMap = new Map();
322
+ for (let li = 0; li < doc.layout.nodeIndex.length; li++) {
323
+ layoutMap.set(doc.layout.nodeIndex[li], li);
324
+ }
325
+ const totalNodes = doc.nodes.backendNodeId.length;
326
+ for (let ni = 0; ni < totalNodes; ni++) {
327
+ const backendNodeId = doc.nodes.backendNodeId[ni];
328
+ // Only process nodes that have a ref (i.e., are in the A11y tree)
329
+ if (!this.refMap.has(backendNodeId))
330
+ continue;
331
+ const li = layoutMap.get(ni);
332
+ // No layout → hidden element
333
+ if (li === undefined) {
334
+ visualMap.set(backendNodeId, {
335
+ bounds: { x: 0, y: 0, w: 0, h: 0 },
336
+ isClickable: this.computeIsClickable(ni, backendNodeId, doc, strings),
337
+ isVisible: false,
338
+ });
339
+ continue;
340
+ }
341
+ // Read bounds
342
+ const boundsArr = doc.layout.bounds[li];
343
+ if (!boundsArr || boundsArr.length < 4)
344
+ continue;
345
+ const [x, y, w, h] = boundsArr;
346
+ // Read computed styles: display, visibility are at indices 0, 1
347
+ const styleProps = doc.layout.styles[li] ?? [];
348
+ const displayVal = this.getSnapshotString(strings, styleProps[0]);
349
+ const visibilityVal = this.getSnapshotString(strings, styleProps[1]);
350
+ // isVisible calculation
351
+ const isVisible = displayVal !== "none" &&
352
+ visibilityVal !== "hidden" &&
353
+ w >= 1 && h >= 1 &&
354
+ x + w > 0 && y + h > 0 &&
355
+ x < EMULATED_WIDTH && y < EMULATED_HEIGHT;
356
+ const isClickable = this.computeIsClickable(ni, backendNodeId, doc, strings);
357
+ visualMap.set(backendNodeId, {
358
+ bounds: {
359
+ x: Math.round(x),
360
+ y: Math.round(y),
361
+ w: Math.round(w),
362
+ h: Math.round(h),
363
+ },
364
+ isClickable,
365
+ isVisible,
366
+ });
367
+ }
368
+ return visualMap;
369
+ }
370
+ /**
371
+ * FR-H5: Enrich nodeInfoMap with HTML attributes (IDs, onclick) and event listeners.
372
+ * Phase 1: DOM.describeNode for HTML IDs + inline onclick detection
373
+ * Phase 2: DOMDebugger.getEventListeners for non-interactive nodes (mousedown, click, pointerdown)
374
+ * Called from both refreshPrecomputed() and getTree() so read_page always has full data.
375
+ */
376
+ async _enrichNodeMetadata(cdpClient, sessionId) {
377
+ const POTENTIALLY_CLICKABLE_ROLES = new Set(["columnheader", "rowheader", "cell", "generic", "listitem"]);
378
+ try {
379
+ const interactiveNodes = [];
380
+ const checkClickNodes = [];
381
+ for (const [backendNodeId, info] of this.nodeInfoMap) {
382
+ if (INTERACTIVE_ROLES.has(info.role) || CONTEXT_ROLES.has(info.role)) {
383
+ interactiveNodes.push(backendNodeId);
384
+ }
385
+ else if (POTENTIALLY_CLICKABLE_ROLES.has(info.role)) {
386
+ interactiveNodes.push(backendNodeId);
387
+ checkClickNodes.push(backendNodeId);
388
+ }
389
+ }
390
+ // Phase 1: Batch DOM.describeNode — extract HTML IDs + inline onclick
391
+ if (interactiveNodes.length > 0 && interactiveNodes.length <= 500) {
392
+ await Promise.allSettled(interactiveNodes.map(async (backendNodeId) => {
393
+ try {
394
+ const desc = await cdpClient.send("DOM.describeNode", { backendNodeId, depth: 0 }, sessionId);
395
+ const attrs = desc.node?.attributes;
396
+ if (!attrs)
397
+ return;
398
+ const info = this.nodeInfoMap.get(backendNodeId);
399
+ if (!info)
400
+ return;
401
+ for (let i = 0; i < attrs.length; i += 2) {
402
+ if (attrs[i] === "id" && attrs[i + 1]) {
403
+ info.htmlId = attrs[i + 1];
404
+ }
405
+ if (attrs[i] === "onclick") {
406
+ info.isClickable = true;
407
+ }
408
+ if (attrs[i] === "target" && attrs[i + 1]) {
409
+ info.linkTarget = attrs[i + 1];
410
+ }
411
+ }
412
+ }
413
+ catch { /* ignore — text nodes etc. don't support describeNode */ }
414
+ }));
415
+ }
416
+ // Phase 2: DOMDebugger.getEventListeners for non-interactive nodes without onclick attribute.
417
+ // Detects addEventListener, React synthetic events, jQuery — anything that registered a click handler.
418
+ const clickCandidates = checkClickNodes.filter(id => !this.nodeInfoMap.get(id)?.isClickable);
419
+ if (clickCandidates.length > 0 && clickCandidates.length <= 200) {
420
+ await Promise.allSettled(clickCandidates.map(async (backendNodeId) => {
421
+ try {
422
+ const resolved = await cdpClient.send("DOM.resolveNode", { backendNodeId }, sessionId);
423
+ const objectId = resolved.object?.objectId;
424
+ if (!objectId)
425
+ return;
426
+ const result = await cdpClient.send("DOMDebugger.getEventListeners", { objectId }, sessionId);
427
+ if (result.listeners?.some(l => l.type === "click" || l.type === "mousedown" || l.type === "pointerdown")) {
428
+ const info = this.nodeInfoMap.get(backendNodeId);
429
+ if (info)
430
+ info.isClickable = true;
431
+ }
432
+ await cdpClient.send("Runtime.releaseObject", { objectId }, sessionId).catch(() => { });
433
+ }
434
+ catch { /* ignore — some nodes can't be resolved */ }
435
+ }));
436
+ }
437
+ // Phase 3: FR-H5 — Enrich unnamed clickable generics with innerText.
438
+ // Separate pass with fresh resolveNode to avoid stale objectId issues.
439
+ const unnamedClickables = [...this.nodeInfoMap.entries()]
440
+ .filter(([, info]) => info.isClickable && !info.name && POTENTIALLY_CLICKABLE_ROLES.has(info.role));
441
+ if (unnamedClickables.length > 0 && unnamedClickables.length <= 100) {
442
+ await Promise.allSettled(unnamedClickables.map(async ([backendNodeId, info]) => {
443
+ try {
444
+ const resolved = await cdpClient.send("DOM.resolveNode", { backendNodeId }, sessionId);
445
+ const oid = resolved.object?.objectId;
446
+ if (!oid)
447
+ return;
448
+ // FR-021: Fetch truncated text AND full length so formatLine can show a truncation marker.
449
+ // Separator \x00 avoids JSON/object marshalling overhead — one string roundtrip, simple split.
450
+ const textResult = await cdpClient.send("Runtime.callFunctionOn", { functionDeclaration: "function(){const t=(this.innerText||this.textContent||'');return t.slice(0,80)+'\\x00'+t.length}", objectId: oid, returnByValue: true }, sessionId);
451
+ if (textResult.result.value) {
452
+ const sep = textResult.result.value.indexOf("\x00");
453
+ if (sep >= 0) {
454
+ const truncated = textResult.result.value.slice(0, sep);
455
+ const fullLength = parseInt(textResult.result.value.slice(sep + 1), 10);
456
+ if (truncated) {
457
+ info.name = truncated;
458
+ if (Number.isFinite(fullLength) && fullLength > truncated.length) {
459
+ info.nameFullLength = fullLength;
460
+ }
461
+ }
462
+ }
463
+ else {
464
+ info.name = textResult.result.value; // Legacy fallback
465
+ }
466
+ }
467
+ await cdpClient.send("Runtime.releaseObject", { objectId: oid }, sessionId).catch(() => { });
468
+ }
469
+ catch { /* non-critical */ }
470
+ }));
471
+ }
472
+ }
473
+ catch {
474
+ // Non-critical — IDs and clickability are nice-to-have
475
+ }
476
+ }
477
+ computeIsClickable(nodeIndex, backendNodeId, doc, strings) {
478
+ // Tag from DOMSnapshot
479
+ const tag = this.getSnapshotString(strings, doc.nodes.nodeName[nodeIndex]);
480
+ if (CLICKABLE_TAGS.has(tag))
481
+ return true;
482
+ // Role from A11y tree nodeInfoMap
483
+ const nodeInfo = this.nodeInfoMap.get(backendNodeId);
484
+ if (nodeInfo && CLICKABLE_ROLES.has(nodeInfo.role))
485
+ return true;
486
+ return false;
487
+ }
488
+ getSnapshotString(strings, index) {
489
+ if (index === undefined || index < 0 || index >= strings.length)
490
+ return "";
491
+ return strings[index];
492
+ }
493
+ findClosestRef(ref, roleFilter) {
494
+ const match = ref.match(/^e(\d+)$/);
495
+ if (!match || this.reverseMap.size === 0)
496
+ return null;
497
+ const requested = parseInt(match[1], 10);
498
+ // Build candidate list, optionally filtered by role
499
+ const candidates = [];
500
+ for (const [refNum, backendNodeId] of this.reverseMap.entries()) {
501
+ const info = this.nodeInfoMap.get(backendNodeId);
502
+ const role = info?.role ?? "";
503
+ if (roleFilter && !roleFilter.has(role))
504
+ continue;
505
+ candidates.push({ refNum, backendNodeId, role, name: info?.name ?? "" });
506
+ }
507
+ if (candidates.length === 0)
508
+ return null;
509
+ let closest = candidates[0];
510
+ let minDist = Math.abs(requested - closest.refNum);
511
+ for (const c of candidates) {
512
+ const dist = Math.abs(requested - c.refNum);
513
+ if (dist < minDist) {
514
+ closest = c;
515
+ minDist = dist;
516
+ }
517
+ }
518
+ return {
519
+ ref: `e${closest.refNum}`,
520
+ role: closest.role,
521
+ name: closest.name,
522
+ };
523
+ }
524
+ async getTree(cdpClient, sessionId, options = {}, sessionManager) {
525
+ const depth = options.depth ?? 3;
526
+ const filter = options.filter ?? "interactive";
527
+ // FR-002: Separate CDP fetch depth from display depth.
528
+ // Interactive/visual filters need deeper fetch to find elements nested beyond display depth.
529
+ // "all" fetches depth+2 so leaf nodes' text children (StaticText) are always included —
530
+ // without this, elements like <strong> at the depth limit appear empty.
531
+ // "landmark" uses moderate depth.
532
+ const cdpFetchDepth = (filter === "interactive" || filter === "visual")
533
+ ? Math.max(depth, 10)
534
+ : filter === "landmark"
535
+ ? Math.max(depth, 6)
536
+ : depth + 2;
537
+ // Navigation detection — reset refs on real navigation (path change),
538
+ // but preserve refs on hash-only changes (anchor navigation).
539
+ const urlResult = await cdpClient.send("Runtime.evaluate", { expression: "document.URL", returnByValue: true }, sessionId);
540
+ const currentUrl = urlResult.result.value;
541
+ if (stripHash(currentUrl) !== stripHash(this.lastUrl)) {
542
+ this.refMap.clear();
543
+ this.reverseMap.clear();
544
+ this.nodeInfoMap.clear();
545
+ this.nextRef = 1;
546
+ this.invalidatePrecomputed();
547
+ }
548
+ this.lastUrl = currentUrl;
549
+ // Precomputed cache check — bypass CDP call if cache is valid (Story 7.4)
550
+ // Subtree queries (options.ref) always load fresh — cached tree may not have full depth
551
+ // M1: Depth mismatch → cache miss (cached depth must be >= requested depth)
552
+ // Story 13a.2 fix: fresh=true bypasses cache (read_page after SPA navigation)
553
+ let nodes;
554
+ if (!options.fresh
555
+ && this._precomputedNodes
556
+ && this._precomputedSessionId === sessionId
557
+ && currentUrl === this._precomputedUrl
558
+ && !options.ref
559
+ && cdpFetchDepth <= this._precomputedDepth) {
560
+ nodes = this._precomputedNodes;
561
+ debug("A11yTreeProcessor: precomputed cache hit");
562
+ }
563
+ else {
564
+ // Fetch A11y tree from CDP — main frame (fallback / cache miss)
565
+ // FR-002: Use cdpFetchDepth (not display depth) so interactive/visual filters find deeply nested elements
566
+ const result = await cdpClient.send("Accessibility.getFullAXTree", { depth: cdpFetchDepth }, sessionId);
567
+ nodes = result.nodes;
568
+ // H1: Prime precomputed cache on fallback (AC #5)
569
+ // Only prime if this is a full-tree query (no ref) with valid nodes
570
+ if (!options.ref && nodes && nodes.length > 0) {
571
+ this._precomputedNodes = nodes;
572
+ this._precomputedUrl = currentUrl;
573
+ this._precomputedSessionId = sessionId;
574
+ this._precomputedDepth = cdpFetchDepth;
575
+ debug("A11yTreeProcessor: cache primed from fallback, %d nodes", nodes.length);
576
+ }
577
+ }
578
+ if (!nodes || nodes.length === 0) {
579
+ const emptyText = this.formatHeader("", 0, filter, depth);
580
+ return {
581
+ text: emptyText,
582
+ refCount: 0,
583
+ depth,
584
+ tokenCount: estimateTokens(emptyText),
585
+ pageUrl: this.lastUrl,
586
+ ...(filter === "visual" ? { hasVisualData: false } : {}),
587
+ };
588
+ }
589
+ // Build nodeId → AXNode map
590
+ const nodeMap = new Map();
591
+ for (const node of nodes) {
592
+ nodeMap.set(node.nodeId, node);
593
+ }
594
+ // Assign refs to all non-ignored nodes with backendDOMNodeId (main frame)
595
+ for (const node of nodes) {
596
+ if (node.ignored || node.backendDOMNodeId === undefined)
597
+ continue;
598
+ if (!this.refMap.has(node.backendDOMNodeId)) {
599
+ const refNum = this.nextRef++;
600
+ this.refMap.set(node.backendDOMNodeId, refNum);
601
+ this.reverseMap.set(refNum, node.backendDOMNodeId);
602
+ }
603
+ // Always update nodeInfoMap with latest role/name
604
+ this.nodeInfoMap.set(node.backendDOMNodeId, extractNodeInfo(node));
605
+ // Track which session owns this node (H1: for cleanup on detach)
606
+ if (!this.sessionNodeMap.has(sessionId)) {
607
+ this.sessionNodeMap.set(sessionId, new Set());
608
+ }
609
+ this.sessionNodeMap.get(sessionId).add(node.backendDOMNodeId);
610
+ // Register node with SessionManager for main frame
611
+ sessionManager?.registerNode(node.backendDOMNodeId, sessionId);
612
+ }
613
+ // Fetch OOPIF A11y trees if SessionManager is available
614
+ const oopifSections = [];
615
+ if (sessionManager) {
616
+ const sessions = sessionManager.getAllSessions();
617
+ const oopifSessions = sessions.filter((s) => !s.isMain);
618
+ if (oopifSessions.length > 0) {
619
+ const oopifResults = await Promise.all(oopifSessions.map(async (s) => {
620
+ try {
621
+ const oopifResult = await cdpClient.send("Accessibility.getFullAXTree", { depth: cdpFetchDepth }, s.sessionId);
622
+ return { url: s.url, nodes: oopifResult.nodes ?? [], sessionId: s.sessionId };
623
+ }
624
+ catch (err) {
625
+ // M1: OOPIF may have been detached between getAllSessions and fetch
626
+ wrapCdpError(err, "A11yTreeProcessor.getTree(OOPIF)");
627
+ return { url: s.url, nodes: [], sessionId: s.sessionId };
628
+ }
629
+ }));
630
+ for (const oopifResult of oopifResults) {
631
+ if (oopifResult.nodes.length > 0) {
632
+ oopifSections.push(oopifResult);
633
+ // Assign refs and register nodes for OOPIF
634
+ for (const node of oopifResult.nodes) {
635
+ // Add to nodeMap with session-prefixed keys to avoid collisions
636
+ nodeMap.set(`${oopifResult.sessionId}:${node.nodeId}`, node);
637
+ if (node.ignored || node.backendDOMNodeId === undefined)
638
+ continue;
639
+ if (!this.refMap.has(node.backendDOMNodeId)) {
640
+ const refNum = this.nextRef++;
641
+ this.refMap.set(node.backendDOMNodeId, refNum);
642
+ this.reverseMap.set(refNum, node.backendDOMNodeId);
643
+ }
644
+ this.nodeInfoMap.set(node.backendDOMNodeId, {
645
+ role: node.role?.value ?? "",
646
+ name: node.name?.value ?? "",
647
+ });
648
+ // Track which session owns this node (H1: for cleanup on detach)
649
+ if (!this.sessionNodeMap.has(oopifResult.sessionId)) {
650
+ this.sessionNodeMap.set(oopifResult.sessionId, new Set());
651
+ }
652
+ this.sessionNodeMap.get(oopifResult.sessionId).add(node.backendDOMNodeId);
653
+ sessionManager.registerNode(node.backendDOMNodeId, oopifResult.sessionId);
654
+ }
655
+ }
656
+ }
657
+ }
658
+ }
659
+ // FR-H5: Enrich nodes with HTML attributes + event listener detection.
660
+ // Runs in getTree() so read_page(filter: "interactive") sees clickable divs/listItems.
661
+ await this._enrichNodeMetadata(cdpClient, sessionId);
662
+ // Fetch visual data only for "visual" filter — zero overhead for other filters
663
+ let visualMap;
664
+ let visualDataFailed = false;
665
+ if (filter === "visual") {
666
+ try {
667
+ visualMap = await this.fetchVisualData(cdpClient, sessionId);
668
+ }
669
+ catch {
670
+ // M1: DOMSnapshot may fail on certain pages — fall back to tree without visual data
671
+ visualMap = undefined;
672
+ visualDataFailed = true;
673
+ }
674
+ }
675
+ // Handle subtree query
676
+ if (options.ref) {
677
+ // For subtree, search across all nodes (main + OOPIF)
678
+ const allNodes = [...nodes];
679
+ for (const section of oopifSections) {
680
+ allNodes.push(...section.nodes);
681
+ }
682
+ // Build a combined nodeMap for subtree
683
+ const combinedNodeMap = new Map();
684
+ for (const node of allNodes) {
685
+ combinedNodeMap.set(node.nodeId, node);
686
+ }
687
+ return this.getSubtree(options.ref, combinedNodeMap, allNodes, filter, depth, visualMap, visualDataFailed, options.max_tokens);
688
+ }
689
+ // Get page title from root node
690
+ const pageTitle = this.getPageTitle(nodes);
691
+ // Build tree text from root (main frame)
692
+ const root = nodes[0];
693
+ const lines = [];
694
+ // Ticket-1: Pre-scan the whole tree (main + OOPIFs) to identify groups
695
+ // of ≥10 same-class leaves that should collapse into summary lines.
696
+ // Must run BEFORE renderNode so anchors/suppressed ids are visible to
697
+ // every walk path.
698
+ this.prepareAggregateGroups(root, nodeMap, oopifSections, filter);
699
+ this.renderNode(root, nodeMap, 0, filter, lines, visualMap);
700
+ // Append OOPIF sections
701
+ for (const section of oopifSections) {
702
+ const oopifNodeMap = new Map();
703
+ for (const node of section.nodes) {
704
+ oopifNodeMap.set(node.nodeId, node);
705
+ }
706
+ lines.push(`--- iframe: ${section.url} ---`);
707
+ if (section.nodes.length > 0) {
708
+ this.renderNode(section.nodes[0], oopifNodeMap, 0, filter, lines, visualMap);
709
+ }
710
+ }
711
+ this.clearAggregateGroups();
712
+ // H5: Count only actual element lines, not separator lines (--- iframe: ... ---)
713
+ const refCount = lines.filter((l) => !l.startsWith("--- ")).length;
714
+ // FR-022: Count content nodes (StaticText/paragraph/etc.) hidden by filter:interactive,
715
+ // so read-page.ts can append a hint pointing the LLM at filter:'all' instead of evaluate.
716
+ const hiddenContentCount = filter === "interactive"
717
+ ? this.countHiddenContentNodes(root, nodeMap, oopifSections)
718
+ : undefined;
719
+ const text = this.formatHeader(pageTitle, refCount, filter, depth)
720
+ + (lines.length > 0 ? "\n\n" + lines.join("\n") : "");
721
+ // Token-Budget Downsampling — explicit max_tokens or BUG-009 safety cap
722
+ const effectiveMaxTokens = options.max_tokens ?? DEFAULT_MAX_TOKENS;
723
+ const currentTokens = estimateTokens(text);
724
+ if (currentTokens > effectiveMaxTokens) {
725
+ const downsampled = this.downsampleTree(root, nodeMap, filter, effectiveMaxTokens, oopifSections, visualMap);
726
+ const dsHeader = this.formatDownsampledHeader(pageTitle, downsampled.refCount, filter, depth, currentTokens, downsampled.level);
727
+ // C2: Final budget check — trim body if header+body exceeds budget
728
+ let dsBody = downsampled.lines.join("\n");
729
+ const fullText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
730
+ if (estimateTokens(fullText) > effectiveMaxTokens && dsBody) {
731
+ const headerTokens = estimateTokens(dsHeader + "\n\n");
732
+ const bodyBudget = effectiveMaxTokens - headerTokens;
733
+ const trimmed = this.trimBodyToFit(downsampled.lines, bodyBudget);
734
+ dsBody = trimmed.join("\n");
735
+ }
736
+ const dsText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
737
+ return {
738
+ text: dsText,
739
+ refCount: downsampled.refCount,
740
+ depth,
741
+ tokenCount: estimateTokens(dsText),
742
+ pageUrl: this.lastUrl,
743
+ downsampled: true,
744
+ originalTokens: currentTokens,
745
+ downsampleLevel: downsampled.level,
746
+ ...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
747
+ ...(hiddenContentCount !== undefined ? { hiddenContentCount } : {}),
748
+ };
749
+ }
750
+ return {
751
+ text,
752
+ refCount,
753
+ depth,
754
+ tokenCount: currentTokens,
755
+ pageUrl: this.lastUrl,
756
+ ...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
757
+ ...(hiddenContentCount !== undefined ? { hiddenContentCount } : {}),
758
+ };
759
+ }
760
+ // --- Downsampling Pipeline (D2Snap) ---
761
+ downsampleTree(root, nodeMap, filter, maxTokens, oopifSections, visualMap) {
762
+ // Try levels 0-4 sequentially until budget is met
763
+ for (let level = 0; level <= 4; level++) {
764
+ const lines = [];
765
+ this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, level, visualMap);
766
+ // Append OOPIF sections
767
+ for (const section of oopifSections) {
768
+ const oopifNodeMap = new Map();
769
+ for (const node of section.nodes) {
770
+ oopifNodeMap.set(node.nodeId, node);
771
+ }
772
+ lines.push(`--- iframe: ${section.url} ---`);
773
+ if (section.nodes.length > 0) {
774
+ this.renderNodeDownsampled(section.nodes[0], oopifNodeMap, 0, filter, lines, level, visualMap);
775
+ }
776
+ }
777
+ const refCount = lines.filter((l) => !l.startsWith("--- ")).length;
778
+ // Estimate tokens including a header estimate (~60 chars)
779
+ const estimatedTotal = estimateTokens(lines.join("\n")) + 15;
780
+ if (estimatedTotal <= maxTokens) {
781
+ return { lines, refCount, level };
782
+ }
783
+ }
784
+ // Level 4 still too large → truncate as last resort
785
+ const lines = [];
786
+ this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, 4, visualMap);
787
+ // Append OOPIF sections
788
+ for (const section of oopifSections) {
789
+ const oopifNodeMap = new Map();
790
+ for (const node of section.nodes) {
791
+ oopifNodeMap.set(node.nodeId, node);
792
+ }
793
+ lines.push(`--- iframe: ${section.url} ---`);
794
+ if (section.nodes.length > 0) {
795
+ this.renderNodeDownsampled(section.nodes[0], oopifNodeMap, 0, filter, lines, 4, visualMap);
796
+ }
797
+ }
798
+ return this.truncateToFit(lines, maxTokens);
799
+ }
800
+ truncateToFit(lines, maxTokens) {
801
+ // C3: Prioritize elements: dialogs/modals > interactive > content
802
+ const dialogLines = [];
803
+ const interactiveLines = [];
804
+ const contentLines = [];
805
+ // Track dialog context: lines inside a dialog subtree get dialog priority
806
+ let insideDialog = false;
807
+ let dialogIndent = -1;
808
+ for (let i = 0; i < lines.length; i++) {
809
+ const line = lines[i];
810
+ const indent = line.search(/\S/);
811
+ // Detect dialog/modal boundaries
812
+ const roleMatch = line.match(/\[e\d+\]\s+(\S+)/);
813
+ const role = roleMatch ? roleMatch[1] : "";
814
+ if (role === "dialog" || role === "alertdialog") {
815
+ insideDialog = true;
816
+ dialogIndent = indent;
817
+ dialogLines.push({ line, idx: i });
818
+ continue;
819
+ }
820
+ // If we were inside a dialog and indentation decreased back to/beyond dialog level, we left the dialog
821
+ if (insideDialog && indent <= dialogIndent && i > 0) {
822
+ insideDialog = false;
823
+ dialogIndent = -1;
824
+ }
825
+ if (insideDialog) {
826
+ dialogLines.push({ line, idx: i });
827
+ }
828
+ else if (/\[e\d+\]/.test(line) && INTERACTIVE_ROLES.has(role)) {
829
+ // Demote option elements to content priority — they fill token budget
830
+ // without adding actionable value (the combobox itself shows the current value)
831
+ if (role === "option") {
832
+ contentLines.push({ line, idx: i });
833
+ }
834
+ else {
835
+ interactiveLines.push({ line, idx: i });
836
+ }
837
+ }
838
+ else {
839
+ contentLines.push({ line, idx: i });
840
+ }
841
+ }
842
+ // Build result: dialogs first, then interactive, then content
843
+ const result = [];
844
+ let tokensSoFar = 15; // header estimate
845
+ const addedIndices = new Set();
846
+ // Phase 0: Add all dialog/modal elements (highest priority — always visible)
847
+ for (const { line, idx } of dialogLines) {
848
+ const lineTokens = estimateTokens(line + "\n");
849
+ if (tokensSoFar + lineTokens > maxTokens - 15)
850
+ break;
851
+ result.push(line);
852
+ addedIndices.add(idx);
853
+ tokensSoFar += lineTokens;
854
+ }
855
+ // Phase 1: Add interactive elements
856
+ for (const { line, idx } of interactiveLines) {
857
+ const lineTokens = estimateTokens(line + "\n");
858
+ if (tokensSoFar + lineTokens > maxTokens - 15)
859
+ break;
860
+ result.push(line);
861
+ addedIndices.add(idx);
862
+ tokensSoFar += lineTokens;
863
+ }
864
+ // Phase 2: Fill remaining budget with content lines
865
+ for (const { line, idx } of contentLines) {
866
+ const lineTokens = estimateTokens(line + "\n");
867
+ if (tokensSoFar + lineTokens > maxTokens - 15)
868
+ break;
869
+ result.push(line);
870
+ addedIndices.add(idx);
871
+ tokensSoFar += lineTokens;
872
+ }
873
+ // Add truncation marker if lines were omitted
874
+ const omitted = lines.length - addedIndices.size;
875
+ if (omitted > 0) {
876
+ result.push(`... (truncated, ${omitted} elements omitted)`);
877
+ }
878
+ // Re-sort by original index to maintain document order
879
+ const sortedResult = result
880
+ .filter((l) => !l.startsWith("..."))
881
+ .map((l) => {
882
+ // Find original index
883
+ const origIdx = lines.indexOf(l);
884
+ return { line: l, idx: origIdx };
885
+ })
886
+ .sort((a, b) => a.idx - b.idx)
887
+ .map((entry) => entry.line);
888
+ // Append truncation marker at the end
889
+ if (omitted > 0) {
890
+ sortedResult.push(`... (truncated, ${omitted} elements omitted)`);
891
+ }
892
+ const refCount = sortedResult.filter((l) => !l.startsWith("--- ") && !l.startsWith("...")).length;
893
+ return { lines: sortedResult, refCount, level: 4 };
894
+ }
895
+ /** C2: Trim body lines from the end (content first) until budget is met */
896
+ trimBodyToFit(lines, bodyBudgetTokens) {
897
+ if (bodyBudgetTokens <= 0)
898
+ return [];
899
+ // Separate interactive and content lines
900
+ const interactiveIndices = new Set();
901
+ for (let i = 0; i < lines.length; i++) {
902
+ const line = lines[i];
903
+ const roleMatch = line.match(/\[e\d+\]\s+(\S+)/);
904
+ const role = roleMatch ? roleMatch[1] : "";
905
+ if (INTERACTIVE_ROLES.has(role)) {
906
+ interactiveIndices.add(i);
907
+ }
908
+ }
909
+ // Try removing content lines from the end first
910
+ const result = [...lines];
911
+ while (estimateTokens(result.join("\n")) > bodyBudgetTokens && result.length > 0) {
912
+ // Find last non-interactive line to remove
913
+ let removedContent = false;
914
+ for (let i = result.length - 1; i >= 0; i--) {
915
+ const roleMatch = result[i].match(/\[e\d+\]\s+(\S+)/);
916
+ const role = roleMatch ? roleMatch[1] : "";
917
+ if (!INTERACTIVE_ROLES.has(role)) {
918
+ result.splice(i, 1);
919
+ removedContent = true;
920
+ break;
921
+ }
922
+ }
923
+ // If only interactive lines remain and still over budget, remove from end
924
+ if (!removedContent) {
925
+ result.pop();
926
+ }
927
+ }
928
+ return result;
929
+ }
930
+ renderNodeDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap) {
931
+ if (node.ignored) {
932
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
933
+ return;
934
+ }
935
+ const role = this.getRole(node);
936
+ const elementClass = classifyElement(role);
937
+ const passesFilter = this.passesFilter(node, role, filter);
938
+ if (!passesFilter) {
939
+ // Not passing filter — skip but process children at same indent
940
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
941
+ return;
942
+ }
943
+ if (node.backendDOMNodeId === undefined) {
944
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
945
+ return;
946
+ }
947
+ const refNum = this.refMap.get(node.backendDOMNodeId);
948
+ if (refNum === undefined) {
949
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
950
+ return;
951
+ }
952
+ const indent = " ".repeat(indentLevel);
953
+ if (elementClass === "interactive") {
954
+ // Interactive: ALWAYS fully preserved
955
+ let line = this.formatLine(indent, refNum, role, node, nodeMap);
956
+ if (visualMap) {
957
+ const vi = visualMap.get(node.backendDOMNodeId);
958
+ if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
959
+ line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
960
+ if (vi.isClickable)
961
+ line += " click";
962
+ if (vi.isVisible)
963
+ line += " vis";
964
+ }
965
+ else {
966
+ line += " [hidden]";
967
+ }
968
+ }
969
+ lines.push(line);
970
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
971
+ }
972
+ else if (elementClass === "content") {
973
+ if (level < 4) {
974
+ // Content at levels 0-3: unchanged
975
+ let line = this.formatLine(indent, refNum, role, node, nodeMap);
976
+ if (visualMap) {
977
+ const vi = visualMap.get(node.backendDOMNodeId);
978
+ if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
979
+ line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
980
+ if (vi.isClickable)
981
+ line += " click";
982
+ if (vi.isVisible)
983
+ line += " vis";
984
+ }
985
+ else {
986
+ line += " [hidden]";
987
+ }
988
+ }
989
+ lines.push(line);
990
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
991
+ }
992
+ else {
993
+ // Content at level 4: compact Markdown
994
+ const name = node.name?.value ?? "";
995
+ if (role === "heading") {
996
+ lines.push(`${indent}# ${name} (e${refNum})`);
997
+ }
998
+ else if (role === "listitem") {
999
+ const truncName = name.length > 100 ? name.slice(0, 97) + "..." : name;
1000
+ lines.push(`${indent}- ${truncName} (e${refNum})`);
1001
+ }
1002
+ else {
1003
+ // paragraph, StaticText, img, etc.
1004
+ const truncName = name.length > 100 ? name.slice(0, 97) + "..." : name;
1005
+ if (truncName) {
1006
+ lines.push(`${indent}${truncName} (e${refNum})`);
1007
+ }
1008
+ }
1009
+ // Still render children for content nodes (they may have interactive children)
1010
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
1011
+ }
1012
+ }
1013
+ else {
1014
+ // Container
1015
+ if (level === 0) {
1016
+ // Level 0: no merging, render normally
1017
+ let line = this.formatLine(indent, refNum, role, node, nodeMap);
1018
+ if (visualMap) {
1019
+ const vi = visualMap.get(node.backendDOMNodeId);
1020
+ if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
1021
+ line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
1022
+ if (vi.isClickable)
1023
+ line += " click";
1024
+ if (vi.isVisible)
1025
+ line += " vis";
1026
+ }
1027
+ else {
1028
+ line += " [hidden]";
1029
+ }
1030
+ }
1031
+ lines.push(line);
1032
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
1033
+ }
1034
+ else if (level === 1) {
1035
+ // Level 1: remove empty containers (no children)
1036
+ const childCount = this.countVisibleChildren(node, nodeMap);
1037
+ if (childCount === 0) {
1038
+ // H3: Even if no visible direct children, check for interactive descendants
1039
+ if (this.hasInteractiveDescendants(node, nodeMap)) {
1040
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
1041
+ }
1042
+ return;
1043
+ }
1044
+ let line = this.formatLine(indent, refNum, role, node, nodeMap);
1045
+ if (visualMap) {
1046
+ const vi = visualMap.get(node.backendDOMNodeId);
1047
+ if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
1048
+ line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
1049
+ if (vi.isClickable)
1050
+ line += " click";
1051
+ if (vi.isVisible)
1052
+ line += " vis";
1053
+ }
1054
+ else {
1055
+ line += " [hidden]";
1056
+ }
1057
+ }
1058
+ lines.push(line);
1059
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
1060
+ }
1061
+ else if (level === 2) {
1062
+ // Level 2: single-child containers merged (child takes container's level)
1063
+ const children = this.getVisibleChildren(node, nodeMap);
1064
+ if (children.length === 0) {
1065
+ // H3: Check for interactive descendants before removing
1066
+ if (this.hasInteractiveDescendants(node, nodeMap)) {
1067
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
1068
+ }
1069
+ return;
1070
+ }
1071
+ if (children.length === 1) {
1072
+ // Merge: child at container's indent level
1073
+ this.renderNodeDownsampled(children[0], nodeMap, indentLevel, filter, lines, level, visualMap);
1074
+ return;
1075
+ }
1076
+ // Multiple children: keep container but render children
1077
+ let line = this.formatLine(indent, refNum, role, node, nodeMap);
1078
+ if (visualMap) {
1079
+ const vi = visualMap.get(node.backendDOMNodeId);
1080
+ if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
1081
+ line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
1082
+ if (vi.isClickable)
1083
+ line += " click";
1084
+ if (vi.isVisible)
1085
+ line += " vis";
1086
+ }
1087
+ else {
1088
+ line += " [hidden]";
1089
+ }
1090
+ }
1091
+ lines.push(line);
1092
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
1093
+ }
1094
+ else {
1095
+ // Level 3-4: container chains flattened, containers as one-line summary
1096
+ const childCount = this.countDescendantElements(node, nodeMap, filter);
1097
+ if (childCount === 0) {
1098
+ // H3: Even with 0 filtered descendants, check for interactive ones
1099
+ if (this.hasInteractiveDescendants(node, nodeMap)) {
1100
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
1101
+ }
1102
+ return;
1103
+ }
1104
+ const name = node.name?.value ?? "";
1105
+ const shortRole = this.shortContainerRole(role);
1106
+ const nameStr = name ? `: ${name}` : "";
1107
+ lines.push(`${indent}[${shortRole}${nameStr}, ${childCount} items]`);
1108
+ // Children rendered at indentLevel + 1
1109
+ this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
1110
+ }
1111
+ }
1112
+ }
1113
+ renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap) {
1114
+ if (!node.childIds)
1115
+ return;
1116
+ for (const childId of node.childIds) {
1117
+ const child = nodeMap.get(childId);
1118
+ if (child) {
1119
+ this.renderNodeDownsampled(child, nodeMap, indentLevel, filter, lines, level, visualMap);
1120
+ }
1121
+ }
1122
+ }
1123
+ countVisibleChildren(node, nodeMap) {
1124
+ if (!node.childIds)
1125
+ return 0;
1126
+ let count = 0;
1127
+ for (const childId of node.childIds) {
1128
+ const child = nodeMap.get(childId);
1129
+ if (child && !child.ignored)
1130
+ count++;
1131
+ }
1132
+ return count;
1133
+ }
1134
+ getVisibleChildren(node, nodeMap) {
1135
+ if (!node.childIds)
1136
+ return [];
1137
+ const children = [];
1138
+ for (const childId of node.childIds) {
1139
+ const child = nodeMap.get(childId);
1140
+ if (child && !child.ignored)
1141
+ children.push(child);
1142
+ }
1143
+ return children;
1144
+ }
1145
+ countDescendantElements(node, nodeMap, filter) {
1146
+ if (!node.childIds)
1147
+ return 0;
1148
+ let count = 0;
1149
+ for (const childId of node.childIds) {
1150
+ const child = nodeMap.get(childId);
1151
+ if (!child || child.ignored)
1152
+ continue;
1153
+ const role = this.getRole(child);
1154
+ if (this.passesFilter(child, role, filter))
1155
+ count++;
1156
+ count += this.countDescendantElements(child, nodeMap, filter);
1157
+ }
1158
+ return count;
1159
+ }
1160
+ /** H3: Check if a node has any interactive descendants (recursive) */
1161
+ hasInteractiveDescendants(node, nodeMap) {
1162
+ if (!node.childIds)
1163
+ return false;
1164
+ for (const childId of node.childIds) {
1165
+ const child = nodeMap.get(childId);
1166
+ if (!child)
1167
+ continue;
1168
+ const role = this.getRole(child);
1169
+ if (INTERACTIVE_ROLES.has(role) && !child.ignored)
1170
+ return true;
1171
+ if (this.hasInteractiveDescendants(child, nodeMap))
1172
+ return true;
1173
+ }
1174
+ return false;
1175
+ }
1176
+ shortContainerRole(role) {
1177
+ const shortcuts = {
1178
+ navigation: "nav",
1179
+ complementary: "aside",
1180
+ contentinfo: "footer",
1181
+ banner: "header",
1182
+ generic: "div",
1183
+ group: "group",
1184
+ region: "region",
1185
+ form: "form",
1186
+ search: "search",
1187
+ toolbar: "toolbar",
1188
+ tablist: "tabs",
1189
+ menu: "menu",
1190
+ menubar: "menubar",
1191
+ list: "list",
1192
+ listbox: "listbox",
1193
+ tree: "tree",
1194
+ grid: "grid",
1195
+ table: "table",
1196
+ main: "main",
1197
+ rowgroup: "rowgroup",
1198
+ row: "row",
1199
+ treegrid: "treegrid",
1200
+ };
1201
+ return shortcuts[role] ?? role;
1202
+ }
1203
+ formatDownsampledHeader(title, count, filter, depth, originalTokens, level) {
1204
+ const titlePart = title ? `Page: ${title}` : "Page";
1205
+ return `${titlePart} — ${count} ${filter} elements (depth ${depth}, downsampled L${level} from ~${originalTokens} tokens)`;
1206
+ }
1207
+ getSubtree(ref, nodeMap, nodes, filter, depth, visualMap, visualDataFailed = false, maxTokens) {
1208
+ const backendId = this.resolveRef(ref);
1209
+ if (backendId === undefined) {
1210
+ const availableRefs = this.getAvailableRefsRange();
1211
+ const suggestion = this.suggestClosestRef(ref);
1212
+ let errorText = `Element ${ref} not found.`;
1213
+ if (availableRefs)
1214
+ errorText += ` Available refs: ${availableRefs}.`;
1215
+ if (suggestion)
1216
+ errorText += ` Did you mean ${suggestion}?`;
1217
+ throw new RefNotFoundError(errorText);
1218
+ }
1219
+ // Find the AXNode with this backendDOMNodeId
1220
+ const targetNode = nodes.find((n) => n.backendDOMNodeId === backendId);
1221
+ if (!targetNode) {
1222
+ throw new RefNotFoundError(`Element ${ref} not found in current tree.`);
1223
+ }
1224
+ const lines = [];
1225
+ this.renderNode(targetNode, nodeMap, 0, filter, lines, visualMap);
1226
+ const refCount = lines.length;
1227
+ const header = `Subtree for ${ref} — ${refCount} elements`;
1228
+ const text = header + "\n\n" + lines.join("\n");
1229
+ // H2: Apply downsampling when max_tokens is set and subtree exceeds budget
1230
+ if (maxTokens) {
1231
+ const currentTokens = estimateTokens(text);
1232
+ if (currentTokens > maxTokens) {
1233
+ // Downsample the subtree using the same pipeline
1234
+ const downsampled = this.downsampleSubtree(targetNode, nodeMap, filter, maxTokens, visualMap);
1235
+ const dsHeader = `Subtree for ${ref} — ${downsampled.refCount} elements (downsampled L${downsampled.level} from ~${currentTokens} tokens)`;
1236
+ // C2: Final budget check on subtree too
1237
+ let dsBody = downsampled.lines.join("\n");
1238
+ const fullText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
1239
+ if (estimateTokens(fullText) > maxTokens && dsBody) {
1240
+ const headerTokens = estimateTokens(dsHeader + "\n\n");
1241
+ const bodyBudget = maxTokens - headerTokens;
1242
+ const trimmed = this.trimBodyToFit(downsampled.lines, bodyBudget);
1243
+ dsBody = trimmed.join("\n");
1244
+ }
1245
+ const dsText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
1246
+ return {
1247
+ text: dsText,
1248
+ refCount: downsampled.refCount,
1249
+ depth,
1250
+ tokenCount: estimateTokens(dsText),
1251
+ pageUrl: this.lastUrl,
1252
+ downsampled: true,
1253
+ originalTokens: currentTokens,
1254
+ downsampleLevel: downsampled.level,
1255
+ ...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
1256
+ };
1257
+ }
1258
+ }
1259
+ return {
1260
+ text,
1261
+ refCount,
1262
+ depth,
1263
+ tokenCount: estimateTokens(text),
1264
+ pageUrl: this.lastUrl,
1265
+ ...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
1266
+ };
1267
+ }
1268
+ /** H2: Downsample a subtree (same algorithm as downsampleTree but for a single root) */
1269
+ downsampleSubtree(root, nodeMap, filter, maxTokens, visualMap) {
1270
+ for (let level = 0; level <= 4; level++) {
1271
+ const lines = [];
1272
+ this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, level, visualMap);
1273
+ const refCount = lines.filter((l) => !l.startsWith("--- ")).length;
1274
+ const estimatedTotal = estimateTokens(lines.join("\n")) + 15;
1275
+ if (estimatedTotal <= maxTokens) {
1276
+ return { lines, refCount, level };
1277
+ }
1278
+ }
1279
+ // Level 4 still too large — truncate as last resort
1280
+ const lines = [];
1281
+ this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, 4, visualMap);
1282
+ return this.truncateToFit(lines, maxTokens);
1283
+ }
1284
+ /** BUG-001: Find the nearest heading sibling before this node in parent's children */
1285
+ findSectionHeading(node, nodeMap) {
1286
+ if (!node.parentId)
1287
+ return null;
1288
+ const parent = nodeMap.get(node.parentId);
1289
+ if (!parent?.childIds)
1290
+ return null;
1291
+ const myIdx = parent.childIds.indexOf(node.nodeId);
1292
+ for (let i = myIdx - 1; i >= 0; i--) {
1293
+ const sibling = nodeMap.get(parent.childIds[i]);
1294
+ if (!sibling)
1295
+ continue;
1296
+ const siblingRole = this.getRole(sibling);
1297
+ if (siblingRole === "heading") {
1298
+ const name = sibling.name?.value;
1299
+ if (name)
1300
+ return name;
1301
+ }
1302
+ }
1303
+ return null;
1304
+ }
1305
+ renderNode(node, nodeMap, indentLevel, filter, lines, visualMap) {
1306
+ if (node.ignored) {
1307
+ // Ignored nodes: skip but process children at same indent level
1308
+ this.renderChildren(node, nodeMap, indentLevel, filter, lines, visualMap);
1309
+ return;
1310
+ }
1311
+ const role = this.getRole(node);
1312
+ const passesFilter = this.passesFilter(node, role, filter);
1313
+ // Ticket-1 global aggregation: suppressed members produce no line AND
1314
+ // no recursion — they are guaranteed leaves by prepareAggregateGroups.
1315
+ if (node.backendDOMNodeId !== undefined &&
1316
+ this._aggregateSuppressed?.has(node.backendDOMNodeId)) {
1317
+ return;
1318
+ }
1319
+ // Anchor members emit the collapse summary at their position instead of
1320
+ // the normal formatLine output, then return (leaves have no children
1321
+ // worth rendering).
1322
+ if (node.backendDOMNodeId !== undefined) {
1323
+ const anchor = this._aggregateAnchors?.get(node.backendDOMNodeId);
1324
+ if (anchor) {
1325
+ this.emitAggregateLine(anchor, indentLevel, lines);
1326
+ return;
1327
+ }
1328
+ }
1329
+ if (passesFilter && node.backendDOMNodeId !== undefined) {
1330
+ const refNum = this.refMap.get(node.backendDOMNodeId);
1331
+ if (refNum !== undefined) {
1332
+ const indent = " ".repeat(indentLevel);
1333
+ let line = this.formatLine(indent, refNum, role, node, nodeMap);
1334
+ // Append visual info if visualMap is provided
1335
+ if (visualMap) {
1336
+ const vi = visualMap.get(node.backendDOMNodeId);
1337
+ if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
1338
+ line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
1339
+ if (vi.isClickable)
1340
+ line += " click";
1341
+ if (vi.isVisible)
1342
+ line += " vis";
1343
+ }
1344
+ else {
1345
+ line += " [hidden]";
1346
+ }
1347
+ }
1348
+ lines.push(line);
1349
+ }
1350
+ }
1351
+ // Always render children (even if this node didn't pass filter)
1352
+ const nextIndent = passesFilter ? indentLevel + 1 : indentLevel;
1353
+ this.renderChildren(node, nodeMap, nextIndent, filter, lines, visualMap);
1354
+ }
1355
+ /**
1356
+ * Ticket-1 / Token-Aggregation: minimum number of same-class leaf elements
1357
+ * inside the rendered subtree before they are collapsed into one summary
1358
+ * line at the first occurrence. Set to 10 so we never aggregate small or
1359
+ * medium lists (button bars, nav menus, dialog actions) but reliably catch
1360
+ * large generated lists like the 240-button benchmark page, even when they
1361
+ * are interleaved with headings/paragraphs/links.
1362
+ */
1363
+ static AGGREGATE_MIN_COUNT = 10;
1364
+ /**
1365
+ * Ticket-1: Per-render state built by {@link prepareAggregateGroups}. Keys
1366
+ * the first backendDOMNodeId of a ≥10-member aggregation bucket to the
1367
+ * info needed to emit the summary line. Null when no aggregation pass has
1368
+ * been executed (e.g. during subtree renders or tests that bypass getTree).
1369
+ */
1370
+ _aggregateAnchors = null;
1371
+ /**
1372
+ * Ticket-1: All non-first member backendDOMNodeIds for ≥10-member buckets.
1373
+ * renderNode skips any node whose backendDOMNodeId is in this set — the
1374
+ * line they would have produced is already covered by the anchor's
1375
+ * summary line.
1376
+ */
1377
+ _aggregateSuppressed = null;
1378
+ /**
1379
+ * Ticket-1: Build a stable aggregation key for a leaf element. Two
1380
+ * sibling leaves share an aggregation class iff their keys are equal:
1381
+ *
1382
+ * - Identical role.
1383
+ * - Either an identical name (e.g. 50× "Submit") OR an identical
1384
+ * name prefix once a trailing run of digits is stripped
1385
+ * (e.g. "Action 1" / "Action 240" → key "button::Action ").
1386
+ *
1387
+ * Returns null when the element shouldn't participate in aggregation
1388
+ * (no role at all). Empty/missing names are treated as their own key
1389
+ * so unnamed buttons within a row still group together.
1390
+ */
1391
+ aggregationKey(role, name) {
1392
+ if (!role)
1393
+ return null;
1394
+ if (!name)
1395
+ return `${role}::`;
1396
+ const m = name.match(/^(.+?)(\d+)$/);
1397
+ if (m)
1398
+ return `${role}::${m[1]}`;
1399
+ return `${role}::${name}`;
1400
+ }
1401
+ /**
1402
+ * Ticket-1: A child is a "renderable leaf" for aggregation purposes if
1403
+ * it would emit exactly one line under the current filter and carries
1404
+ * no descendants that would also render. We only need to look one level
1405
+ * deep — text wrappers like <span> / <strong> inside a <button> are
1406
+ * either ignored or non-interactive and never produce their own line.
1407
+ */
1408
+ isRenderableLeaf(node, nodeMap, filter) {
1409
+ if (node.ignored)
1410
+ return false;
1411
+ if (node.backendDOMNodeId === undefined)
1412
+ return false;
1413
+ if (this.refMap.get(node.backendDOMNodeId) === undefined)
1414
+ return false;
1415
+ const role = this.getRole(node);
1416
+ if (!this.passesFilter(node, role, filter))
1417
+ return false;
1418
+ if (!node.childIds || node.childIds.length === 0)
1419
+ return true;
1420
+ for (const childId of node.childIds) {
1421
+ const child = nodeMap.get(childId);
1422
+ if (!child || child.ignored)
1423
+ continue;
1424
+ if (child.backendDOMNodeId === undefined)
1425
+ continue;
1426
+ const childRole = this.getRole(child);
1427
+ if (this.passesFilter(child, childRole, filter)) {
1428
+ // The child would render its own line → parent is not a leaf.
1429
+ return false;
1430
+ }
1431
+ }
1432
+ return true;
1433
+ }
1434
+ /**
1435
+ * Ticket-1: Walk the renderable subtree (main + OOPIFs) and compute which
1436
+ * leaves should be collapsed into summary lines. Leaves are bucketed by
1437
+ * aggregation key; any bucket with ≥{@link AGGREGATE_MIN_COUNT} members
1438
+ * becomes a collapse group. The first member in DOM order becomes the
1439
+ * "anchor" (its position emits the summary line) and the rest land in
1440
+ * the suppressed set so renderNode skips them.
1441
+ *
1442
+ * This runs independently of the ≥10-consecutive-siblings assumption,
1443
+ * which is why it catches the T4.7 benchmark case where 120 "Action N"
1444
+ * buttons are interleaved with headings, paragraphs, and inputs inside
1445
+ * 60 sections that share a single DOM parent.
1446
+ */
1447
+ prepareAggregateGroups(root, nodeMap, oopifSections, filter) {
1448
+ const buckets = new Map();
1449
+ const walk = (node, map) => {
1450
+ if (!node.ignored &&
1451
+ node.backendDOMNodeId !== undefined &&
1452
+ this.isRenderableLeaf(node, map, filter)) {
1453
+ const role = this.getRole(node);
1454
+ const name = node.name?.value ?? "";
1455
+ const key = this.aggregationKey(role, name);
1456
+ if (key !== null) {
1457
+ let bucket = buckets.get(key);
1458
+ if (!bucket) {
1459
+ bucket = [];
1460
+ buckets.set(key, bucket);
1461
+ }
1462
+ bucket.push(node);
1463
+ // Leaves by definition have no renderable descendants — skip recursion.
1464
+ return;
1465
+ }
1466
+ }
1467
+ if (node.childIds) {
1468
+ for (const childId of node.childIds) {
1469
+ const child = map.get(childId);
1470
+ if (child)
1471
+ walk(child, map);
1472
+ }
1473
+ }
1474
+ };
1475
+ walk(root, nodeMap);
1476
+ for (const section of oopifSections) {
1477
+ const oopifMap = new Map();
1478
+ for (const n of section.nodes)
1479
+ oopifMap.set(n.nodeId, n);
1480
+ if (section.nodes.length > 0)
1481
+ walk(section.nodes[0], oopifMap);
1482
+ }
1483
+ this._aggregateAnchors = new Map();
1484
+ this._aggregateSuppressed = new Set();
1485
+ for (const bucket of buckets.values()) {
1486
+ if (bucket.length < A11yTreeProcessor.AGGREGATE_MIN_COUNT)
1487
+ continue;
1488
+ const first = bucket[0];
1489
+ const last = bucket[bucket.length - 1];
1490
+ const firstId = first.backendDOMNodeId;
1491
+ const firstRef = this.refMap.get(firstId);
1492
+ const lastRef = this.refMap.get(last.backendDOMNodeId);
1493
+ if (firstRef === undefined || lastRef === undefined)
1494
+ continue;
1495
+ this._aggregateAnchors.set(firstId, {
1496
+ count: bucket.length,
1497
+ role: this.getRole(first),
1498
+ firstName: first.name?.value ?? "",
1499
+ lastName: last.name?.value ?? "",
1500
+ firstRef,
1501
+ lastRef,
1502
+ });
1503
+ for (let i = 1; i < bucket.length; i++) {
1504
+ this._aggregateSuppressed.add(bucket[i].backendDOMNodeId);
1505
+ }
1506
+ }
1507
+ }
1508
+ /** Reset the per-render aggregation state set up by prepareAggregateGroups. */
1509
+ clearAggregateGroups() {
1510
+ this._aggregateAnchors = null;
1511
+ this._aggregateSuppressed = null;
1512
+ }
1513
+ /**
1514
+ * Ticket-1: Emit the summary line for a collapse-group anchor. Format is
1515
+ * intentionally compact and still carries the addressable ref band so the
1516
+ * LLM can click({ ref: "eN" }) on any individual element inside it.
1517
+ */
1518
+ emitAggregateLine(anchor, indentLevel, lines) {
1519
+ const indent = " ".repeat(indentLevel);
1520
+ let line = `${indent}[e${anchor.firstRef}..e${anchor.lastRef}] ${anchor.count}× ${anchor.role}`;
1521
+ if (anchor.firstName && anchor.lastName && anchor.firstName !== anchor.lastName) {
1522
+ line += ` "${anchor.firstName}" .. "${anchor.lastName}"`;
1523
+ }
1524
+ else if (anchor.firstName) {
1525
+ line += ` "${anchor.firstName}"`;
1526
+ }
1527
+ lines.push(line);
1528
+ }
1529
+ renderChildren(node, nodeMap, indentLevel, filter, lines, visualMap) {
1530
+ if (!node.childIds)
1531
+ return;
1532
+ for (const childId of node.childIds) {
1533
+ const child = nodeMap.get(childId);
1534
+ if (child) {
1535
+ this.renderNode(child, nodeMap, indentLevel, filter, lines, visualMap);
1536
+ }
1537
+ }
1538
+ }
1539
+ /** FR-022: Count content nodes with visible text that would be hidden by filter:interactive.
1540
+ * Used to append a hint in read-page.ts that points the LLM at filter:'all' instead of evaluate. */
1541
+ countHiddenContentNodes(root, nodeMap, oopifSections) {
1542
+ let count = 0;
1543
+ const walk = (node, map) => {
1544
+ if (node.ignored) {
1545
+ // Still walk ignored children — they may wrap visible content
1546
+ }
1547
+ else {
1548
+ const role = node.role?.value ?? "";
1549
+ if (CONTENT_ROLES.has(role)) {
1550
+ const name = node.name?.value ?? "";
1551
+ // Only count nodes with actual visible text content
1552
+ if (name && name.trim().length > 0) {
1553
+ count++;
1554
+ }
1555
+ }
1556
+ }
1557
+ if (node.childIds) {
1558
+ for (const childId of node.childIds) {
1559
+ const child = map.get(childId);
1560
+ if (child)
1561
+ walk(child, map);
1562
+ }
1563
+ }
1564
+ };
1565
+ walk(root, nodeMap);
1566
+ for (const section of oopifSections) {
1567
+ const oopifNodeMap = new Map();
1568
+ for (const n of section.nodes)
1569
+ oopifNodeMap.set(n.nodeId, n);
1570
+ if (section.nodes.length > 0)
1571
+ walk(section.nodes[0], oopifNodeMap);
1572
+ }
1573
+ return count;
1574
+ }
1575
+ getRole(node) {
1576
+ return node.role?.value ?? "";
1577
+ }
1578
+ passesFilter(node, role, filter) {
1579
+ if (filter === "all")
1580
+ return true;
1581
+ if (filter === "landmark")
1582
+ return LANDMARK_ROLES.has(role);
1583
+ // interactive and visual filters use the same element selection
1584
+ if (INTERACTIVE_ROLES.has(role))
1585
+ return true;
1586
+ // FR-005: Elements with onclick handlers (e.g. sortable table headers)
1587
+ if (node.backendDOMNodeId !== undefined) {
1588
+ const info = this.nodeInfoMap.get(node.backendDOMNodeId);
1589
+ if (info?.isClickable)
1590
+ return true;
1591
+ }
1592
+ // Check focusable property
1593
+ if (node.properties) {
1594
+ for (const prop of node.properties) {
1595
+ if (prop.name === "focusable" && prop.value.value === true)
1596
+ return true;
1597
+ }
1598
+ }
1599
+ return false;
1600
+ }
1601
+ formatLine(indent, refNum, role, node, nodeMap) {
1602
+ // FR-004: Append HTML id if available
1603
+ const backendNodeId = node.backendDOMNodeId;
1604
+ const htmlId = backendNodeId !== undefined ? this.nodeInfoMap.get(backendNodeId)?.htmlId : undefined;
1605
+ const idSuffix = htmlId ? `#${htmlId}` : "";
1606
+ let line = `${indent}[e${refNum}] ${role}${idSuffix}`;
1607
+ // FR-H5: Prefer AXNode name, fall back to nodeInfoMap (enriched by Phase 3 for clickable generics)
1608
+ const name = node.name?.value
1609
+ || (backendNodeId !== undefined ? this.nodeInfoMap.get(backendNodeId)?.name : undefined);
1610
+ if (name) {
1611
+ line += ` "${name}"`;
1612
+ // FR-021: Signal truncation so the LLM knows hidden text exists (reached for evaluate otherwise)
1613
+ if (backendNodeId !== undefined) {
1614
+ const fullLen = this.nodeInfoMap.get(backendNodeId)?.nameFullLength;
1615
+ if (fullLen && fullLen > name.length) {
1616
+ const extra = fullLen - name.length;
1617
+ line += ` …[+${extra} chars; use filter:"all" with ref to read subtree]`;
1618
+ }
1619
+ }
1620
+ }
1621
+ const value = node.value?.value;
1622
+ if (value !== undefined && value !== "") {
1623
+ line += ` value="${value}"`;
1624
+ }
1625
+ // BUG-001: Annotate tables with nearest heading for disambiguation
1626
+ if (role === "table" && nodeMap) {
1627
+ const sectionHeading = this.findSectionHeading(node, nodeMap);
1628
+ if (sectionHeading) {
1629
+ line += ` (section: "${sectionHeading}")`;
1630
+ }
1631
+ }
1632
+ // FR-003: iframe content hint
1633
+ if (role === "Iframe") {
1634
+ line += " (use evaluate to access iframe content)";
1635
+ }
1636
+ // FR-008: Canvas is opaque — direct LLM to use screenshot(som: true)
1637
+ if (role === "canvas" || role === "Canvas") {
1638
+ line += " ⚠ Canvas content is pixels, not DOM. Use screenshot(som: true) to see what's inside.";
1639
+ }
1640
+ // URL for links — shorten to path to save tokens
1641
+ if (role === "link" && node.properties) {
1642
+ for (const prop of node.properties) {
1643
+ if (prop.name === "url" && prop.value.value) {
1644
+ line += ` → ${shortenUrl(String(prop.value.value))}`;
1645
+ break;
1646
+ }
1647
+ }
1648
+ // FR-002: target=_blank annotation
1649
+ if (backendNodeId !== undefined) {
1650
+ const linkInfo = this.nodeInfoMap.get(backendNodeId);
1651
+ if (linkInfo?.linkTarget === "_blank") {
1652
+ line += " (opens new tab)";
1653
+ }
1654
+ }
1655
+ }
1656
+ // Disabled marker
1657
+ if (node.properties) {
1658
+ for (const prop of node.properties) {
1659
+ if (prop.name === "disabled" && prop.value.value === true) {
1660
+ line += " (disabled)";
1661
+ break;
1662
+ }
1663
+ }
1664
+ }
1665
+ // FR-H6: Tab selected state — helps LLM understand which tab panel is visible
1666
+ if (role === "tab" && node.properties) {
1667
+ for (const prop of node.properties) {
1668
+ if (prop.name === "selected" && prop.value.value === true) {
1669
+ line += " (selected)";
1670
+ break;
1671
+ }
1672
+ }
1673
+ }
1674
+ // FR-006: contenteditable annotation
1675
+ if (node.properties) {
1676
+ for (const prop of node.properties) {
1677
+ if (prop.name === "editable" && prop.value.value && prop.value.value !== "inherit") {
1678
+ line += " (editable)";
1679
+ break;
1680
+ }
1681
+ }
1682
+ }
1683
+ // FR-001: scrollable container annotation
1684
+ if (backendNodeId !== undefined) {
1685
+ const scrollInfo = this.nodeInfoMap.get(backendNodeId);
1686
+ if (scrollInfo?.isScrollable) {
1687
+ line += " (scrollable)";
1688
+ }
1689
+ }
1690
+ return line;
1691
+ }
1692
+ formatHeader(title, count, filter, depth) {
1693
+ const titlePart = title ? `Page: ${title}` : "Page";
1694
+ return `${titlePart} — ${count} ${filter} elements (depth ${depth})`;
1695
+ }
1696
+ getPageTitle(nodes) {
1697
+ // Root node (WebArea) usually has the page title as name
1698
+ if (nodes.length > 0) {
1699
+ const root = nodes[0];
1700
+ if (!root.ignored) {
1701
+ const name = root.name?.value;
1702
+ if (name)
1703
+ return name;
1704
+ }
1705
+ }
1706
+ return "";
1707
+ }
1708
+ getAvailableRefsRange() {
1709
+ if (this.reverseMap.size === 0)
1710
+ return null;
1711
+ const refs = [...this.reverseMap.keys()].sort((a, b) => a - b);
1712
+ return `e${refs[0]}-e${refs[refs.length - 1]}`;
1713
+ }
1714
+ suggestClosestRef(ref) {
1715
+ const match = ref.match(/^e(\d+)$/);
1716
+ if (!match || this.reverseMap.size === 0)
1717
+ return null;
1718
+ const requested = parseInt(match[1], 10);
1719
+ const refs = [...this.reverseMap.keys()].sort((a, b) => a - b);
1720
+ let closest = refs[0];
1721
+ let minDist = Math.abs(requested - closest);
1722
+ for (const r of refs) {
1723
+ const dist = Math.abs(requested - r);
1724
+ if (dist < minDist) {
1725
+ closest = r;
1726
+ minDist = dist;
1727
+ }
1728
+ }
1729
+ return `e${closest}`;
1730
+ }
1731
+ /**
1732
+ * Story 13a.2: Classify a ref for pre-click ambient context decision.
1733
+ * Returns classification based on cached AXNode properties (0 CDP calls).
1734
+ */
1735
+ classifyRef(ref) {
1736
+ const match = ref.match(/^e?(\d+)$/);
1737
+ if (!match)
1738
+ return "static";
1739
+ const refNum = parseInt(match[1], 10);
1740
+ const backendNodeId = this.reverseMap.get(refNum);
1741
+ if (backendNodeId === undefined)
1742
+ return "static";
1743
+ const info = this.nodeInfoMap.get(backendNodeId);
1744
+ if (!info)
1745
+ return "static";
1746
+ if (info.disabled)
1747
+ return "disabled";
1748
+ // hasPopup can be "false" (string) — only classify as widget-state for truthy popup types
1749
+ if (info.expanded !== undefined
1750
+ || (info.hasPopup !== undefined && info.hasPopup !== "false")
1751
+ || info.checked !== undefined
1752
+ || info.pressed !== undefined)
1753
+ return "widget-state";
1754
+ if (INTERACTIVE_ROLES.has(info.role) || info.isClickable)
1755
+ return "clickable";
1756
+ return "static";
1757
+ }
1758
+ /**
1759
+ * FR-008: Return a compact list of known interactive elements for error hints.
1760
+ * Used when a CSS selector fails to provide the LLM with actionable alternatives.
1761
+ * ZERO CDP calls — purely in-memory from cached nodeInfoMap.
1762
+ */
1763
+ getInteractiveElements(limit = 8) {
1764
+ if (this.reverseMap.size === 0)
1765
+ return [];
1766
+ const lines = [];
1767
+ const sortedRefs = [...this.reverseMap.entries()].sort((a, b) => a[0] - b[0]);
1768
+ for (const [refNum, backendNodeId] of sortedRefs) {
1769
+ const info = this.nodeInfoMap.get(backendNodeId);
1770
+ if (!info || !(INTERACTIVE_ROLES.has(info.role) || info.isClickable))
1771
+ continue;
1772
+ const name = info.name ? ` '${info.name}'` : "";
1773
+ const idSuffix = info.htmlId ? `#${info.htmlId}` : "";
1774
+ lines.push(`[e${refNum}] ${info.role}${idSuffix}${name}`);
1775
+ if (lines.length >= limit)
1776
+ break;
1777
+ }
1778
+ return lines;
1779
+ }
1780
+ /**
1781
+ * FR-002: Lightweight snapshot map for DOM-Diff.
1782
+ * Returns Map<refNum, "role\0name"> for all nodes with a name.
1783
+ * ZERO CDP calls — purely in-memory.
1784
+ */
1785
+ getSnapshotMap() {
1786
+ const map = new Map();
1787
+ for (const [refNum, backendNodeId] of this.reverseMap) {
1788
+ const info = this.nodeInfoMap.get(backendNodeId);
1789
+ if (!info || (!info.name && !CONTEXT_ROLES.has(info.role) && !INTERACTIVE_ROLES.has(info.role) && !info.isClickable))
1790
+ continue;
1791
+ map.set(refNum, `${info.role}\0${info.name ?? ""}`);
1792
+ }
1793
+ return map;
1794
+ }
1795
+ /**
1796
+ * FR-002: Compute diff between two snapshot maps.
1797
+ * Returns only meaningful changes (role+name), ignoring nodes without names.
1798
+ */
1799
+ static diffSnapshots(before, after) {
1800
+ const changes = [];
1801
+ // Changed or removed
1802
+ for (const [refNum, beforeVal] of before) {
1803
+ const afterVal = after.get(refNum);
1804
+ if (afterVal === undefined) {
1805
+ // Node removed
1806
+ const [role, name] = beforeVal.split("\0");
1807
+ if (name) { // Only report if it had visible content
1808
+ changes.push({ type: "removed", ref: `e${refNum}`, role, after: "", before: name });
1809
+ }
1810
+ }
1811
+ else if (afterVal !== beforeVal) {
1812
+ // Node changed
1813
+ const [roleBefore, nameBefore] = beforeVal.split("\0");
1814
+ const [roleAfter, nameAfter] = afterVal.split("\0");
1815
+ // Only report if the *name* (visible text) actually changed
1816
+ if (nameBefore !== nameAfter) {
1817
+ changes.push({
1818
+ type: "changed",
1819
+ ref: `e${refNum}`,
1820
+ role: roleAfter || roleBefore,
1821
+ before: nameBefore,
1822
+ after: nameAfter,
1823
+ });
1824
+ }
1825
+ }
1826
+ }
1827
+ // Added
1828
+ for (const [refNum, afterVal] of after) {
1829
+ if (!before.has(refNum)) {
1830
+ const [role, name] = afterVal.split("\0");
1831
+ if (name) { // Only report if it has visible content
1832
+ changes.push({ type: "added", ref: `e${refNum}`, role, after: name });
1833
+ }
1834
+ }
1835
+ }
1836
+ return changes;
1837
+ }
1838
+ /**
1839
+ * FR-002: Format DOM changes as compact context string for LLM.
1840
+ * Prioritizes alerts/status, then shows changes near the action, caps at ~30 lines.
1841
+ */
1842
+ static formatDomDiff(changes, url) {
1843
+ if (changes.length === 0)
1844
+ return null;
1845
+ // Sort: alerts/status first, then added, then changed, then removed
1846
+ const priority = (c) => {
1847
+ if (c.role === "alert" || c.role === "status")
1848
+ return 0;
1849
+ if (c.type === "added")
1850
+ return 1;
1851
+ if (c.type === "changed")
1852
+ return 2;
1853
+ return 3;
1854
+ };
1855
+ changes.sort((a, b) => priority(a) - priority(b));
1856
+ const lines = [];
1857
+ const urlSuffix = url ? ` — ${shortenUrl(url)}` : "";
1858
+ lines.push(`--- Action Result (${changes.length} changes)${urlSuffix} ---`);
1859
+ const maxLines = 30;
1860
+ for (const c of changes.slice(0, maxLines)) {
1861
+ const refTag = INTERACTIVE_ROLES.has(c.role) || CONTEXT_ROLES.has(c.role)
1862
+ ? `[${c.ref}] ` : "";
1863
+ if (c.type === "added") {
1864
+ const roleLabel = c.role === "alert" || c.role === "status" ? c.role : c.role;
1865
+ lines.push(` NEW ${refTag}${roleLabel} "${c.after}"`);
1866
+ }
1867
+ else if (c.type === "changed") {
1868
+ lines.push(` CHANGED ${refTag}${c.role} "${c.before}" → "${c.after}"`);
1869
+ }
1870
+ else {
1871
+ lines.push(` REMOVED ${refTag}${c.role} "${c.before}"`);
1872
+ }
1873
+ }
1874
+ if (changes.length > maxLines) {
1875
+ lines.push(`... (${changes.length - maxLines} more changes)`);
1876
+ }
1877
+ return lines.join("\n");
1878
+ }
1879
+ /**
1880
+ * Story 13a.2: Enriched compact snapshot with headings, alerts, status
1881
+ * plus interactive elements. ZERO CDP calls — purely in-memory.
1882
+ */
1883
+ getCompactSnapshot(maxTokens = 2000) {
1884
+ if (this.reverseMap.size === 0)
1885
+ return null;
1886
+ const contextLines = [];
1887
+ const interactiveLines = [];
1888
+ let tokensSoFar = 0;
1889
+ const sortedRefs = [...this.reverseMap.entries()].sort((a, b) => a[0] - b[0]);
1890
+ for (const [refNum, backendNodeId] of sortedRefs) {
1891
+ const info = this.nodeInfoMap.get(backendNodeId);
1892
+ if (!info)
1893
+ continue;
1894
+ let line = null;
1895
+ // Story 13a.2: Context roles (headings, alerts, status) for orientation
1896
+ if (CONTEXT_ROLES.has(info.role)) {
1897
+ if (info.role === "heading" && info.name) {
1898
+ const lvl = info.level ?? 1;
1899
+ line = `[h${lvl}] "${info.name}"`;
1900
+ }
1901
+ else if ((info.role === "alert" || info.role === "status") && info.name) {
1902
+ line = `[${info.role}] "${info.name}"`;
1903
+ }
1904
+ if (line) {
1905
+ const lineTokens = Math.ceil(line.length / 4);
1906
+ if (tokensSoFar + lineTokens <= maxTokens) {
1907
+ contextLines.push(line);
1908
+ tokensSoFar += lineTokens;
1909
+ }
1910
+ }
1911
+ continue;
1912
+ }
1913
+ // Interactive elements (existing behavior) + FR-005: onclick-clickable elements
1914
+ if (!INTERACTIVE_ROLES.has(info.role) && !info.isClickable)
1915
+ continue;
1916
+ const name = info.name ? ` '${info.name}'` : "";
1917
+ const idSuffix = info.htmlId ? `#${info.htmlId}` : "";
1918
+ line = `[e${refNum}] ${info.role}${idSuffix}${name}`;
1919
+ const lineTokens = Math.ceil(line.length / 4);
1920
+ if (tokensSoFar + lineTokens > maxTokens) {
1921
+ const remaining = sortedRefs.filter(([, id]) => INTERACTIVE_ROLES.has(this.nodeInfoMap.get(id)?.role ?? "")).length - interactiveLines.length;
1922
+ interactiveLines.push(`... (${remaining} more)`);
1923
+ break;
1924
+ }
1925
+ interactiveLines.push(line);
1926
+ tokensSoFar += lineTokens;
1927
+ }
1928
+ if (contextLines.length === 0 && interactiveLines.length === 0)
1929
+ return null;
1930
+ const url = this.lastUrl ? ` — ${shortenUrl(this.lastUrl)}` : "";
1931
+ const parts = [];
1932
+ const counts = interactiveLines.length > 0 ? `${interactiveLines.length} interactive` : `${contextLines.length} context`;
1933
+ parts.push(`--- Page Context (${counts})${url} ---`);
1934
+ if (contextLines.length > 0)
1935
+ parts.push(...contextLines);
1936
+ if (interactiveLines.length > 0)
1937
+ parts.push(...interactiveLines);
1938
+ return parts.join("\n");
1939
+ }
1940
+ }
1941
+ function shortenUrl(url) {
1942
+ try {
1943
+ const parsed = new URL(url);
1944
+ return parsed.pathname + parsed.search + parsed.hash;
1945
+ }
1946
+ catch {
1947
+ return url;
1948
+ }
1949
+ }
1950
+ export class RefNotFoundError extends Error {
1951
+ constructor(message) {
1952
+ super(message);
1953
+ this.name = "RefNotFoundError";
1954
+ }
1955
+ }
1956
+ export const a11yTree = new A11yTreeProcessor();