@tangle-network/browser-agent-driver 0.22.0 → 0.24.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 (75) hide show
  1. package/README.md +357 -126
  2. package/dist/brain/index.d.ts +27 -0
  3. package/dist/brain/index.d.ts.map +1 -1
  4. package/dist/brain/index.js +492 -41
  5. package/dist/brain/index.js.map +1 -1
  6. package/dist/browser-launch.d.ts +2 -0
  7. package/dist/browser-launch.d.ts.map +1 -1
  8. package/dist/browser-launch.js +12 -5
  9. package/dist/browser-launch.js.map +1 -1
  10. package/dist/captcha.d.ts +0 -7
  11. package/dist/captcha.d.ts.map +1 -1
  12. package/dist/captcha.js +113 -4
  13. package/dist/captcha.js.map +1 -1
  14. package/dist/cli.js +69 -13
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config.d.ts +7 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/drivers/cursor-overlay.d.ts +1 -1
  21. package/dist/drivers/cursor-overlay.d.ts.map +1 -1
  22. package/dist/drivers/cursor-overlay.js +21 -9
  23. package/dist/drivers/cursor-overlay.js.map +1 -1
  24. package/dist/drivers/extract-with-index.d.ts +45 -0
  25. package/dist/drivers/extract-with-index.d.ts.map +1 -0
  26. package/dist/drivers/extract-with-index.js +141 -0
  27. package/dist/drivers/extract-with-index.js.map +1 -0
  28. package/dist/drivers/playwright.d.ts +7 -0
  29. package/dist/drivers/playwright.d.ts.map +1 -1
  30. package/dist/drivers/playwright.js +232 -1
  31. package/dist/drivers/playwright.js.map +1 -1
  32. package/dist/drivers/som-overlay.d.ts +36 -0
  33. package/dist/drivers/som-overlay.d.ts.map +1 -0
  34. package/dist/drivers/som-overlay.js +109 -0
  35. package/dist/drivers/som-overlay.js.map +1 -0
  36. package/dist/drivers/types.d.ts +2 -0
  37. package/dist/drivers/types.d.ts.map +1 -1
  38. package/dist/memory/knowledge.d.ts +6 -0
  39. package/dist/memory/knowledge.d.ts.map +1 -1
  40. package/dist/memory/knowledge.js +15 -0
  41. package/dist/memory/knowledge.js.map +1 -1
  42. package/dist/recovery.d.ts.map +1 -1
  43. package/dist/recovery.js +2 -0
  44. package/dist/recovery.js.map +1 -1
  45. package/dist/run-state.d.ts +27 -1
  46. package/dist/run-state.d.ts.map +1 -1
  47. package/dist/run-state.js +40 -1
  48. package/dist/run-state.js.map +1 -1
  49. package/dist/runner/goal-decomposer.d.ts +38 -0
  50. package/dist/runner/goal-decomposer.d.ts.map +1 -0
  51. package/dist/runner/goal-decomposer.js +125 -0
  52. package/dist/runner/goal-decomposer.js.map +1 -0
  53. package/dist/runner/parallel-runner.d.ts +61 -0
  54. package/dist/runner/parallel-runner.d.ts.map +1 -0
  55. package/dist/runner/parallel-runner.js +133 -0
  56. package/dist/runner/parallel-runner.js.map +1 -0
  57. package/dist/runner/pattern-extractor.d.ts +40 -0
  58. package/dist/runner/pattern-extractor.d.ts.map +1 -0
  59. package/dist/runner/pattern-extractor.js +122 -0
  60. package/dist/runner/pattern-extractor.js.map +1 -0
  61. package/dist/runner/runner.d.ts +21 -0
  62. package/dist/runner/runner.d.ts.map +1 -1
  63. package/dist/runner/runner.js +315 -18
  64. package/dist/runner/runner.js.map +1 -1
  65. package/dist/supervisor/critic.d.ts.map +1 -1
  66. package/dist/supervisor/critic.js +1 -0
  67. package/dist/supervisor/critic.js.map +1 -1
  68. package/dist/supervisor/policy.js +10 -0
  69. package/dist/supervisor/policy.js.map +1 -1
  70. package/dist/test-runner.d.ts.map +1 -1
  71. package/dist/test-runner.js +8 -3
  72. package/dist/test-runner.js.map +1 -1
  73. package/dist/types.d.ts +94 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +3 -1
@@ -26,6 +26,7 @@ ACTIONS:
26
26
  - {"action": "wait", "ms": 1000}
27
27
  - {"action": "evaluate", "criteria": "Is the layout professional? Are colors consistent?"}
28
28
  - {"action": "runScript", "script": "document.querySelector('.count').textContent"} — run JS in page context and get the result. Use for reading content not in the a11y tree (canvas, computed styles, hidden state).
29
+ - {"action": "extractWithIndex", "query": "p, span, dd, code", "contains": "downloads"} — return a NUMBERED list of every visible element matching \`query\`, with each element's tag, full textContent, key attributes, and a stable selector. PREFER THIS OVER runScript when you need to find data inside the page but don't know the exact selector. The wide query (e.g. \`'p, span, strong'\`) finds candidates and the response shows the actual text so you can pick by content match. Optional \`contains\` filters matches to those whose text contains a substring (case-insensitive). After this action, your next turn can complete with the picked element's text or click its selector.
29
30
  - {"action": "verifyPreview"} — after the app builds, inspect the preview iframe. Returns URL, title, a11y tree, and errors. Use this AFTER you see a preview iframe on the page.
30
31
  - {"action": "fill", "fields": {"@t1": "Jordan", "@t2": "Rivera"}, "selects": {"@s1": "WA"}, "checks": ["@c1", "@c2"]} — BATCH fill multiple form fields, dropdowns, and checkboxes in ONE turn. Use this whenever you can see 2+ form fields you need to fill — it's dramatically faster than per-field type/click. fields/selects/checks are all optional but at least one must be non-empty.
31
32
  - {"action": "clickSequence", "refs": ["@r1", "@r2", "@r3"]} — click a known sequence of refs in order. Use for multi-step UI navigation chains where the click order is obvious from the page structure.
@@ -72,17 +73,59 @@ const SEARCH_RULES = `
72
73
  15. SEARCH FORMS: Always interact with the form (type in search box, then click Search or press Enter). Do NOT navigate to a URL with search query parameters — many sites require form submission to trigger filtering. If a search yields no results, try the page's own search box rather than the site-wide search
73
74
  16. CONTENT DISCOVERY: If the ELEMENTS list doesn't show the link/content you need (e.g., the page has many links but the a11y tree is truncated), use runScript to find it: document.querySelectorAll('a[href]') filtered by keyword. Navigate to the discovered URL directly instead of clicking blindly through menus
74
75
  17. EXTERNAL SEARCH REDIRECTS: If a site's search form redirects to an external search engine (e.g., search.usa.gov for .gov sites), the results still link back to the original site. Click a relevant search result link — it will take you to the target domain. Do NOT abandon search results to navigate the target site manually`;
75
- /** Data extraction rules (18, 21-23): injected when goal involves extracting data */
76
+ /** Data extraction rules (18, 21-23, 25): injected when goal involves extracting data */
76
77
  const DATA_EXTRACTION_RULES = `
77
- 18. DATA EXTRACTION: When the goal asks for specific data (prices, ratings, counts, names) from a list or search results page, use runScript to extract all needed data at once: e.g., document.querySelectorAll('.product-card').forEach(...). Do NOT click into each individual item when the data is visible on the list page. Extract first, then complete with the extracted data
78
- 21. EFFICIENT COMPLETION: When you have enough data to answer the goal, complete immediately. Do not navigate to additional pages for "confirmation" if the data was already extracted via runScript or is visible in the current a11y tree. Include all extracted data in the completion result
79
- 22. EXTRACT BEFORE NAVIGATING: On search results, directory listings, or any page showing multiple items, ALWAYS extract ALL needed data via runScript BEFORE clicking into individual items. This includes names, phone numbers, addresses, ratings, prices anything visible on list cards. Use: document.querySelectorAll('.result-card, .listing, [class*="card"]') to grab everything at once. Many sites use anti-bot protection on detail pages but leave listing pages accessible. If you can answer the goal from list-level data, do so without navigating deeper. NEVER click into 3+ individual items when the data is on the list page
80
- 23. FILTER vs SEARCH: When a goal asks to filter results (e.g., "under $50", "4+ stars"), look for filter controls (sliders, dropdowns, checkboxes in a sidebar or toolbar) rather than typing filter values into the search box. Search boxes are for keyword queries, not numeric filters. After applying a filter: (1) wait 2-3 seconds for results to update, (2) verify the filter took effect by checking the updated results, (3) extract the filtered data via runScript. Do NOT keep searching for more filter controls after one is applied — extract and complete`;
78
+ 18. DATA EXTRACTION: When the goal asks for specific data (prices, ratings, counts, names) from a list or search results page, prefer extractWithIndex with a wide query (e.g. \`'p, span, dd, code, strong'\`) over runScript when you don't already see the value in the snapshot. extractWithIndex returns the actual textContent of every match so you can pick the right one by content. Use runScript only when you need a transformation the LLM can't do from text alone.
79
+ 21. EFFICIENT COMPLETION: When you have enough data to answer the goal, complete immediately. Do not navigate to additional pages for "confirmation" if the data was already extracted or is visible in the current a11y tree. Include all extracted data in the completion result
80
+ 22. EXTRACT BEFORE NAVIGATING: On search results, directory listings, or any page showing multiple items, ALWAYS extract ALL needed data BEFORE clicking into individual items. Use extractWithIndex with a wide query for unknown structure, or runScript with document.querySelectorAll('.result-card') if the structure is well-known. Many sites use anti-bot protection on detail pages but leave listing pages accessible. If you can answer the goal from list-level data, do so without navigating deeper.
81
+ 23. FILTER vs SEARCH: When a goal asks to filter results (e.g., "under $50", "4+ stars"), look for filter controls (sliders, dropdowns, checkboxes in a sidebar or toolbar) rather than typing filter values into the search box. Search boxes are for keyword queries, not numeric filters. After applying a filter: (1) wait 2-3 seconds for results to update, (2) verify the filter took effect by checking the updated results, (3) extract the filtered data. Do NOT keep searching for more filter controls after one is applied — extract and complete
82
+ 25. EXTRACTWITHINDEX RECOVERY: If a previous runScript returned null/empty/{x:null} on an extraction task, the selector was wrong. DO NOT retry the same runScript or guess a similar selector — the LLM cannot guess CSS class names that aren't visible in the snapshot. Switch to extractWithIndex with a WIDE query: \`'p, span, dd, code, strong, em'\` plus a \`contains\` filter naming the expected text fragment (e.g. contains: "downloads" for npm download counts, contains: "callbackFn" for MDN method signatures). The response shows you the actual text per element so you can pick by content match. This is the Gen 10 recovery pattern — pick-by-content beats pick-by-selector on every page where the planner couldn't see the data at plan time.`;
81
83
  /** Heavy page rules (19-20, 24): injected when snapshot is large or turn count is high */
82
84
  const HEAVY_PAGE_RULES = `
83
85
  19. FORM FIELD TARGETING: Before typing, verify you are targeting the correct input field using its @ref from the ELEMENTS list. If multiple inputs are visible (e.g., search box + price filter), ensure you select the right one by checking its label or placeholder text in the a11y tree. Never assume focus — always specify the exact @ref
84
86
  20. SECTION NAVIGATION: When you need to find a specific section (e.g., rugby, sports, travel) and the nav links aren't in the truncated a11y tree, use runScript to discover navigation: JSON.stringify(Array.from(document.querySelectorAll('nav a, header a, [role="navigation"] a, .nav a')).slice(0, 30).map(a => ({text: a.textContent.trim(), href: a.href}))). Then navigate directly to the matching section URL
85
87
  24. HEAVY PAGE RECOVERY: If a page takes very long to load or seems stuck, do NOT wait — use runScript to check document.readyState and extract whatever content is already in the DOM. Partial data is better than a timeout. If the page is completely blank, try navigating to a simpler version (mobile site, search page) instead of waiting`;
88
+ /** Gen 24: URL-first navigation — GENERAL PURPOSE, not site-specific.
89
+ * Teaches the agent to construct search/results URLs from goal text
90
+ * instead of fighting form UIs. Works on any site with URL parameters. */
91
+ const URL_FIRST_RULES = `
92
+ URL-FIRST NAVIGATION: When a search form is complex (date pickers, multi-step dropdowns, dynamic widgets), try constructing a results URL directly instead of interacting with the form.
93
+
94
+ STRATEGY:
95
+ 1. Look at the current URL structure. Most search sites encode parameters: ?q=query, ?checkin=date, ?dest=city, etc.
96
+ 2. Construct a URL with the goal's parameters filled in. Use the site's own URL pattern.
97
+ 3. Navigate directly to that URL — skip the form entirely.
98
+ 4. If the URL doesn't work (wrong page, error), fall back to form interaction.
99
+
100
+ HOW TO DISCOVER URL PATTERNS:
101
+ - If you're on a search results page, the URL already shows the pattern. Modify the parameters for your goal.
102
+ - Most sites accept ?q= or ?search= for keyword queries.
103
+ - Travel sites typically use: checkin/checkout dates, destination/origin, adults count.
104
+ - Use runScript to read window.location.href if the URL isn't visible in the snapshot.
105
+ - ENCODED PARAMETERS: If a URL contains encoded parameters (base64, protobuf), you may be able to replicate them from a previous successful URL, but do NOT invent new encodings. If a navigate with encoded parameters lands on the wrong page, do not retry with a different encoding — it will waste turns.
106
+
107
+ IMPORTANT EXCEPTIONS — some sites BLOCK direct URL navigation:
108
+ - If a direct URL navigate lands on the homepage or an error page instead of results, the site blocks URL manipulation. STOP trying URLs and use the site's form/search UI instead.
109
+ - After ONE failed URL attempt, switch to form interaction immediately. Do NOT retry different URL patterns — you will waste turns.
110
+
111
+ SEARCH ENGINE FALLBACK: If BOTH URL construction AND form interaction have stalled (3+ turns on the same form with no progress), use DuckDuckGo as a proxy:
112
+ 1. Navigate to https://duckduckgo.com/?q={goal rephrased as a search query}. Example: goal "Find cheapest one-way flight from NYC to London on Jan 15" → https://duckduckgo.com/?q=cheapest+one+way+flight+NYC+to+London+January+15+2026
113
+ 2. Read the search results to find the answer, OR click through to a comparison site (Kayak, Skyscanner, Google Flights via result link) that has the data.
114
+ 3. IMPORTANT: Do NOT use google.com/search — it blocks automated browsers with CAPTCHA pages. Always use duckduckgo.com instead.
115
+ 4. This is a LAST RESORT after URL construction and direct form interaction have both failed. Do not jump to search on the first turn.
116
+
117
+ FORM RESET DETECTION: Some sites (especially SPAs) silently reset form fields after filling. After batch-filling a form:
118
+ 1. Use runScript to verify values stuck: document.querySelector('[aria-label="From"]')?.value or similar.
119
+ 2. If fields reset to defaults (wrong city, blank dates), do NOT re-fill with the same approach — it will reset again.
120
+ 3. Instead, switch to keyboard-only interaction: click the field, type the value character by character, wait for autocomplete dropdown, press Enter to confirm. Then Tab to the next field.
121
+ 4. If the form keeps resetting after 3 fill attempts, use the search engine fallback immediately.
122
+
123
+ DATE PICKER STRATEGY: Calendar widgets often ignore programmatic fill/type. When a date field opens a calendar popup that blocks further input:
124
+ 1. Try typing the date directly into the field in the site's format (e.g., "Jan 25, 2026" or "01/25/2026"). Press Escape first if the calendar covers the input.
125
+ 2. If typing doesn't stick, use runScript to find clickable date elements: document.querySelectorAll('[data-iso],[aria-label*="January"],[aria-label*="25"]') and click the matching element.
126
+ 3. NEVER spend more than 4 turns on a single date field. If it's not set after 4 attempts, switch to the search engine fallback above.
127
+
128
+ WHY: Complex forms with date pickers, calendar widgets, and multi-step dropdowns consume many turns and often time out. A single "navigate" action replaces 5-10 form interaction turns. But only use this on sites that support it.`;
86
129
  /** Reasoning framework and examples (always appended after rules) */
87
130
  const REASONING_SUFFIX = `
88
131
 
@@ -103,6 +146,134 @@ EXAMPLE 3 — Batch fill a multi-field form (one turn instead of ten):
103
146
  {"plan":["Fill all visible Personal Info fields","Click Next","Fill Contact step","Submit"],"currentStep":0,"action":{"action":"fill","fields":{"@firstname":"Jordan","@lastname":"Rivera","@dob":"1990-04-15"},"selects":{"@gender":"other"}},"reasoning":"Step 1 of the form has 3 text fields and 1 select all visible at once. Filling them in a single batch action saves 7 turns vs typing each individually.","expectedEffect":"All four Step 1 fields populated with the supplied values"}`;
104
147
  /** Full static prompt (all rules) — used as default when config.systemPrompt is not set */
105
148
  const SYSTEM_PROMPT = CORE_RULES + SEARCH_RULES + DATA_EXTRACTION_RULES + HEAVY_PAGE_RULES + REASONING_SUFFIX;
149
+ // Gen 13: Vision-first system prompt (pure coordinate actions).
150
+ const VISION_FIRST_PROMPT = `You are a browser automation agent. You operate by looking at screenshots and clicking on elements using pixel coordinates.
151
+
152
+ The screenshot shows the current page state at 1024×768 resolution. You identify elements visually and specify where to click using (x, y) coordinates in this coordinate space.
153
+
154
+ ACTIONS:
155
+ - {"action": "clickAt", "x": 512, "y": 384} — click at pixel coordinates (x, y) in 1024×768 space
156
+ - {"action": "typeAt", "x": 300, "y": 200, "text": "search query"} — click at coordinates then type text
157
+ - {"action": "scroll", "direction": "up" | "down", "amount": 500}
158
+ - {"action": "navigate", "url": "https://..."}
159
+ - {"action": "wait", "ms": 1000}
160
+ - {"action": "complete", "result": "description of what was accomplished"}
161
+ - {"action": "abort", "reason": "why you cannot continue"}
162
+ - {"action": "runScript", "script": "document.querySelector('.count').textContent"} — run JS in page context for data the screenshot can't show
163
+ - {"action": "extractWithIndex", "query": "p, span, dd", "contains": "keyword"} — find text in the DOM by content match
164
+
165
+ COORDINATE SYSTEM:
166
+ - (0, 0) is the top-left corner of the viewport
167
+ - (1024, 768) is the bottom-right corner
168
+ - Click the CENTER of the target element, not its edge
169
+ - For text inputs, click the middle of the input field
170
+ - For buttons, click the center of the button text or icon
171
+
172
+ RESPONSE FORMAT — respond with ONLY a JSON object:
173
+ {
174
+ "plan": ["step 1", "step 2", ...],
175
+ "currentStep": 0,
176
+ "action": { "action": "clickAt", "x": 512, "y": 384 },
177
+ "reasoning": "I see [element description] at approximately (x, y). Clicking it to [purpose].",
178
+ "expectedEffect": "What should change after this action"
179
+ }
180
+
181
+ RULES:
182
+ 1. Respond with ONLY valid JSON, no markdown or extra text
183
+ 2. LOOK at the screenshot carefully — it is your primary information source
184
+ 3. Include plan, currentStep, reasoning, and expectedEffect in every response
185
+ 4. When the goal is achieved, use "complete" with a detailed result description
186
+ 5. If stuck after multiple attempts, use "abort" — don't loop forever
187
+ 6. If an action failed, try a DIFFERENT approach (different location, different strategy)
188
+ 7. For search: click the search box, type your query, then press Enter
189
+ 8. For navigation: click visible links or use the "navigate" action for direct URLs
190
+ 9. BLOCKER-FIRST: if a modal, cookie banner, or error dialog blocks progress, dismiss it first
191
+ 10. Use runScript or extractWithIndex when you need to extract data that isn't clearly visible in the screenshot
192
+ 11. BATCH: when filling forms, you can type in one field, then immediately use clickAt on the next field. Plan multiple actions per turn when they are sequential and obvious.
193
+ 12. VERIFY BEFORE COMPLETING: Before using "complete", re-read the GOAL and check: does your result ACTUALLY answer what was asked? If the goal asks for "5 beauty salons with ratings > 4.8" and you only found 3, do NOT complete — keep searching. If the goal asks for a specific date/price/name and your result doesn't contain it, do NOT complete. Premature completion with wrong data is worse than using another turn.
194
+ 13. DATE PICKER BYPASS: If you encounter a complex date picker widget (calendar popup, date spinner) that is hard to interact with, DO NOT spend multiple turns clicking through calendar months. Instead, use "navigate" to construct a URL with the date parameters encoded. For Google Flights: navigate to google.com/travel/flights with search params. For Booking: navigate to booking.com/searchresults with checkin/checkout params. URL-based date setting is faster and more reliable than fighting date picker UIs.
195
+
196
+ REASONING FRAMEWORK:
197
+ 1. What do I see in the screenshot? Describe the visual layout.
198
+ 2. Where is the element I need to interact with? Estimate its (x, y) coordinates.
199
+ 3. What is the smallest action that makes progress toward the goal?
200
+ 4. If my last action failed, WHY did it fail? Try a different location or strategy.`;
201
+ // Gen 15: Unified vision+DOM prompt. The model sees BOTH the screenshot AND
202
+ // the ARIA snapshot with @refs. It can use EITHER coordinate actions (clickAt/
203
+ // typeAt for visual targets) OR ref actions (click/type/fill for DOM elements).
204
+ // This lets it pick the best tool per interaction: vision for visual layout,
205
+ // DOM for precise form interaction.
206
+ const UNIFIED_VISION_DOM_PROMPT = `You are a browser automation agent with TWO input modalities: a screenshot showing the visual page state, and a structured ELEMENTS list with interactive element refs.
207
+
208
+ Use BOTH together:
209
+ - The screenshot shows layout, visual state, images, icons — what a human sees
210
+ - The ELEMENTS list shows interactive elements with @ref IDs for precise targeting
211
+
212
+ ACTIONS — pick the best tool for each interaction:
213
+
214
+ LABEL ACTIONS (PREFERRED — use the [N] numbered labels visible on the screenshot):
215
+ - {"action": "clickLabel", "label": 3} — click element labeled [3] in the screenshot
216
+ - {"action": "typeLabel", "label": 5, "text": "query"} — click [5] then type text
217
+ The screenshot has numbered red badges on interactive elements. Use these labels — they're MORE ACCURATE than coordinate guessing.
218
+
219
+ REF ACTIONS (use for form fields, buttons, links with clear @refs from ELEMENTS):
220
+ - {"action": "click", "selector": "@REF"}
221
+ - {"action": "type", "selector": "@REF", "text": "text"}
222
+ - {"action": "press", "selector": "@REF", "key": "Enter"}
223
+ - {"action": "select", "selector": "@REF", "value": "option"}
224
+ - {"action": "fill", "fields": {"@REF1": "val1", "@REF2": "val2"}} — batch fill multiple form fields
225
+
226
+ COORDINATE ACTIONS (fallback when no label or ref is available):
227
+ - {"action": "clickAt", "x": 512, "y": 384} — click at pixel (x, y) in 1024×768 space
228
+ - {"action": "typeAt", "x": 300, "y": 200, "text": "query"} — click + type
229
+
230
+ SHARED ACTIONS:
231
+ - {"action": "scroll", "direction": "up" | "down", "amount": 500}
232
+ - {"action": "navigate", "url": "https://..."}
233
+ - {"action": "wait", "ms": 1000}
234
+ - {"action": "runScript", "script": "..."} — run JS in page context
235
+ - {"action": "extractWithIndex", "query": "p, span", "contains": "keyword"}
236
+ - {"action": "complete", "result": "description"}
237
+ - {"action": "abort", "reason": "why"}
238
+
239
+ WHEN TO USE WHICH (priority order):
240
+ 1. Element has a [N] label in the screenshot → use clickLabel/typeLabel (most accurate)
241
+ 2. Element has an @ref in ELEMENTS → use click/type/fill (fast and precise)
242
+ 3. Element is visible but has no label or ref → use clickAt/typeAt (coordinate fallback)
243
+ - Date pickers, dropdown items rendered dynamically → use clickLabel if labeled, else clickAt
244
+
245
+ RESPONSE FORMAT — respond with ONLY a JSON object:
246
+ {
247
+ "plan": ["step 1", "step 2", ...],
248
+ "currentStep": 0,
249
+ "action": { "action": "click", "selector": "@REF" },
250
+ "nextActions": [{ "action": "type", "selector": "@REF2", "text": "query" }],
251
+ "reasoning": "Why I chose this action",
252
+ "expectedEffect": "What should change"
253
+ }
254
+
255
+ NOTE: "nextActions" is optional — include up to 3 safe follow-up actions (click, type, press, clickAt, typeAt, scroll) that are DETERMINISTIC given the current state. For example: click a search box THEN type a query. This saves turns.
256
+
257
+ RULES:
258
+ 1. Respond with ONLY valid JSON
259
+ 2. Use @ref selectors from ELEMENTS when available — they are stable and precise
260
+ 3. Fall back to clickAt coordinates when the target has no ref or is visual-only
261
+ 4. LOOK at the screenshot — it shows visual state the ELEMENTS list may miss
262
+ 5. When the goal is achieved, use "complete" with a detailed result
263
+ 6. BLOCKER-FIRST: dismiss modals, cookie banners, login walls before continuing
264
+ 7. BATCH FILL: when 2+ form fields are visible with refs, use a single "fill" action
265
+ 8. If stuck after multiple attempts, use "abort"
266
+ 9. VERIFY BEFORE COMPLETING: Before using "complete", re-read the GOAL and check: does your result ACTUALLY answer what was asked? If the goal asks for specific data (prices, names, ratings, counts) and your result doesn't contain ALL of them, keep going. Premature completion with partial data is worse than using another turn.
267
+ 10. FORM RESET DETECTION: After batch-filling a form, verify values stuck via runScript. If fields reset to defaults, switch to keyboard-only: click field → type value → wait for autocomplete → press Enter → Tab to next. Do NOT re-fill with the same approach if it reset once.
268
+ 11. DATE PICKER STRATEGY: When a calendar popup opens over a date field:
269
+ a. Press Escape to dismiss, then type the date directly (e.g., "Jan 25, 2026").
270
+ b. If typing doesn't stick, use runScript to find clickable dates: document.querySelectorAll('[data-iso],[aria-label*="25"]').
271
+ c. NEVER spend more than 4 turns on a single date field. Switch to search fallback after 4 attempts.
272
+ 12. SEARCH ENGINE FALLBACK: When BOTH form interaction AND URL construction have failed (3+ turns stuck):
273
+ a. Navigate to https://duckduckgo.com/?q={goal rephrased as a search query}
274
+ b. Read search results or click through to comparison sites (Kayak, Skyscanner) for the data.
275
+ c. IMPORTANT: Do NOT use google.com/search — it blocks automated browsers with CAPTCHAs. Always use duckduckgo.com.
276
+ d. This is a LAST RESORT — do not use it as a first approach.`;
106
277
  /** Pattern for detecting data-extraction keywords in goal text */
107
278
  const DATA_EXTRACTION_PATTERN = /\b(extract|list|find|data|price|pric|names?|rating|cost|count)\b/i;
108
279
  /** Pattern for detecting search-related roles in snapshot text */
@@ -199,6 +370,7 @@ export class Brain {
199
370
  maxHistoryTurns;
200
371
  visionEnabled;
201
372
  visionStrategy;
373
+ observationMode;
202
374
  llmTimeoutMs;
203
375
  compactFirstTurn;
204
376
  lastDecisionUrl;
@@ -206,6 +378,13 @@ export class Brain {
206
378
  scoutModelName;
207
379
  scoutProvider;
208
380
  scoutUseVision;
381
+ // Gen 28: per-role model overrides
382
+ plannerModel;
383
+ plannerProvider;
384
+ verifierModel;
385
+ verifierProvider;
386
+ supervisorModel;
387
+ supervisorProvider;
209
388
  sandboxBackendType;
210
389
  sandboxBackendProfile;
211
390
  sandboxBackendProvider;
@@ -228,6 +407,7 @@ export class Brain {
228
407
  this.maxHistoryTurns = config.maxHistoryTurns || 10;
229
408
  this.visionEnabled = config.vision !== false;
230
409
  this.visionStrategy = config.visionStrategy ?? (this.visionEnabled ? 'always' : 'never');
410
+ this.observationMode = config.observationMode ?? 'dom';
231
411
  this.compactFirstTurn = config.compactFirstTurn === true;
232
412
  this.sandboxBackendType = config.sandboxBackendType;
233
413
  this.sandboxBackendProfile = config.sandboxBackendProfile;
@@ -235,6 +415,19 @@ export class Brain {
235
415
  this.scoutModelName = config.scout?.model;
236
416
  this.scoutProvider = config.scout?.provider;
237
417
  this.scoutUseVision = config.scout?.useVision === true;
418
+ // Gen 28: per-role model overrides
419
+ this.plannerModel = config.models?.planner?.model;
420
+ this.plannerProvider = config.models?.planner?.provider;
421
+ this.verifierModel = config.models?.verifier?.model;
422
+ this.verifierProvider = config.models?.verifier?.provider;
423
+ this.supervisorModel = config.models?.supervisor?.model;
424
+ this.supervisorProvider = config.models?.supervisor?.provider;
425
+ // executor uses navModel (already wired) — config.models.executor overrides it
426
+ if (config.models?.executor) {
427
+ this.navModelName = config.models.executor.model;
428
+ this.navProvider = config.models.executor.provider || this.navProvider;
429
+ this.adaptiveModelRouting = true; // enable routing when executor model is set
430
+ }
238
431
  }
239
432
  resolveModelName(provider, requestedModel) {
240
433
  return resolveProviderModelName(provider, requestedModel, {
@@ -517,10 +710,18 @@ export class Brain {
517
710
  * Current strategy: nav model is ONLY used for verification (see verifyGoalCompletion),
518
711
  * not for decide(). The flag is kept for future experiments with better routing signals.
519
712
  */
520
- shouldUseNavigationModel(_state, _extraContext, _turnInfo) {
521
- // Disabled for decide() primary model is more cost-effective overall.
522
- // Verification still routes to nav model (separate code path).
523
- return false;
713
+ shouldUseNavigationModel(state, extraContext, turnInfo) {
714
+ if (!this.adaptiveModelRouting || !this.navModelName)
715
+ return false;
716
+ // Gen 22: use cheap model for DOM-only same-page turns (form filling,
717
+ // clicking known elements). Keep expensive model for first turn, new
718
+ // pages, and error recovery where reasoning matters.
719
+ const isFirstTurn = !turnInfo || turnInfo.current <= 1;
720
+ const samePageAsPrevious = this.lastDecisionUrl === state.url;
721
+ const hasError = extraContext?.includes('REJECTED') || extraContext?.includes('ERROR');
722
+ if (isFirstTurn || !samePageAsPrevious || hasError)
723
+ return false;
724
+ return true;
524
725
  }
525
726
  /**
526
727
  * Build the system prompt dynamically, injecting conditional rule groups
@@ -850,6 +1051,10 @@ export class Brain {
850
1051
  });
851
1052
  }
852
1053
  async decide(goal, state, extraContext, turnInfo, options) {
1054
+ // Gen 13: vision-first or hybrid mode — delegate to the vision path
1055
+ if (this.observationMode === 'vision' || this.observationMode === 'hybrid') {
1056
+ return this.decideVision(goal, state, extraContext, turnInfo);
1057
+ }
853
1058
  const useCompactFirstTurn = this.compactFirstTurn && turnInfo?.current === 1;
854
1059
  const samePageAsPrevious = this.lastDecisionUrl === state.url;
855
1060
  const isFirstTurn = !turnInfo || turnInfo.current <= 1;
@@ -865,8 +1070,11 @@ export class Brain {
865
1070
  && diffChanges > 0
866
1071
  && diffTotal > 0
867
1072
  && diffChanges / diffTotal < 0.3;
868
- // Tighter snapshot budget on same-page turns — agent already saw the full page
869
- const snapshotBudget = samePageAsPrevious ? 8_000 : 16_000;
1073
+ // Tighter snapshot budget on same-page turns — agent already saw the full page.
1074
+ // Gen 10C: raised new-page budget from 16k to 24k so extraction tasks (MDN/Python
1075
+ // docs/W3C spec) get the full <dl>/<code>/<pre> content the LLM needs to write
1076
+ // a working runScript on the first try.
1077
+ const snapshotBudget = samePageAsPrevious ? 8_000 : 24_000;
870
1078
  let visibleSnapshot;
871
1079
  let elementsHeader;
872
1080
  if (useDiffOnly) {
@@ -1013,6 +1221,135 @@ ${visibleSnapshot}`;
1013
1221
  modelUsed: effectiveModel,
1014
1222
  };
1015
1223
  }
1224
+ /**
1225
+ * Gen 13: Vision-first decision path. The screenshot is the primary
1226
+ * observation; DOM snapshot is minimal context (URL, title only in pure
1227
+ * vision mode, or compact DOM in hybrid mode). The LLM outputs
1228
+ * coordinate-based actions (clickAt, typeAt) in 1024×768 virtual space.
1229
+ */
1230
+ async decideVision(goal, state, extraContext, turnInfo) {
1231
+ this.lastDecisionUrl = state.url;
1232
+ // Gen 18: adaptive observation — diff-focused on same-page turns.
1233
+ // When the page changed slightly after an action (modal opened, dropdown
1234
+ // expanded, content loaded), the DIFF is the signal. Send only what
1235
+ // changed instead of the full snapshot. Saves 3-5k tokens per turn.
1236
+ const isHybrid = this.observationMode === 'hybrid';
1237
+ const samePageAsPrevious = this.lastDecisionUrl === state.url;
1238
+ const isFirstTurn = !turnInfo || turnInfo.current <= 1;
1239
+ const rawDiff = state.snapshotDiffRaw;
1240
+ const diffChanges = rawDiff ? rawDiff.added.length + rawDiff.removed.length + rawDiff.changed.length : 0;
1241
+ const diffTotal = rawDiff ? diffChanges + rawDiff.unchangedCount : 0;
1242
+ const useDiffOnly = isHybrid && samePageAsPrevious && !isFirstTurn
1243
+ && rawDiff !== undefined && diffChanges > 0 && diffTotal > 0
1244
+ && diffChanges / diffTotal < 0.4;
1245
+ let textContent = `GOAL: ${goal}
1246
+
1247
+ CURRENT PAGE:
1248
+ URL: ${state.url}
1249
+ Title: ${state.title}`;
1250
+ if (isHybrid && state.snapshot) {
1251
+ if (useDiffOnly) {
1252
+ // Diff-focused: only what changed since last turn
1253
+ const lines = [];
1254
+ if (rawDiff.added.length)
1255
+ lines.push('ADDED:', ...rawDiff.added);
1256
+ if (rawDiff.changed.length)
1257
+ lines.push('CHANGED:', ...rawDiff.changed);
1258
+ if (rawDiff.removed.length)
1259
+ lines.push('REMOVED:', ...rawDiff.removed);
1260
+ lines.push(`(${rawDiff.unchangedCount} elements unchanged — refs from previous turn still valid)`);
1261
+ textContent += `\n\nPAGE CHANGES (what changed after your last action — this is the important part):\n${lines.join('\n')}`;
1262
+ }
1263
+ else {
1264
+ // Progressive budget reduction: more turns on same page = less snapshot
1265
+ // needed (agent has already seen the full page, rely on screenshot + diff).
1266
+ const sameTurnCount = samePageAsPrevious ? (turnInfo?.current || 0) : 0;
1267
+ const snapshotBudget = samePageAsPrevious
1268
+ ? (sameTurnCount >= 8 ? 2_500 : 4_000) // aggressive after 8+ same-page turns
1269
+ : 6_000;
1270
+ const snap = budgetSnapshot(state.snapshot, snapshotBudget);
1271
+ textContent += `\n\nELEMENTS:\n${snap}`;
1272
+ }
1273
+ }
1274
+ if (turnInfo) {
1275
+ const remaining = turnInfo.max - turnInfo.current;
1276
+ textContent += `\n\nTURN: ${turnInfo.current}/${turnInfo.max} (${remaining} remaining)`;
1277
+ if (remaining === 1) {
1278
+ textContent += ` — FINAL TURN: return a terminal action only (complete or abort)`;
1279
+ }
1280
+ else if (remaining <= 3) {
1281
+ textContent += ` — RUNNING LOW, prioritize completing the goal or aborting`;
1282
+ }
1283
+ }
1284
+ if (extraContext) {
1285
+ textContent += `\n\n${extraContext}`;
1286
+ }
1287
+ textContent += '\n\nLook at the screenshot. What action should you take?';
1288
+ // Screenshot is required for vision-first mode
1289
+ if (!state.screenshot) {
1290
+ return {
1291
+ action: { action: 'wait', ms: 500 },
1292
+ reasoning: 'No screenshot available for vision-first mode — waiting for page to render',
1293
+ raw: '{"action":{"action":"wait","ms":500}}',
1294
+ };
1295
+ }
1296
+ const userContent = [
1297
+ { type: 'text', text: textContent },
1298
+ { type: 'image', image: state.screenshot, mediaType: 'image/jpeg' },
1299
+ ];
1300
+ // Gen 14: strip ALL screenshots from history. The current turn's
1301
+ // screenshot is the only image the model needs — old screenshots are
1302
+ // dead weight that pushes cumulative token count past the cost cap.
1303
+ // This alone fixes 3/5 Gen 13 failures (cost_cap at 101-106k).
1304
+ const compacted = this.compactHistory().map((msg) => {
1305
+ if (msg.role !== 'user' || !Array.isArray(msg.content))
1306
+ return msg;
1307
+ const textOnly = msg.content
1308
+ .filter((part) => part.type === 'text');
1309
+ if (textOnly.length === msg.content.length)
1310
+ return msg;
1311
+ return { ...msg, content: textOnly };
1312
+ });
1313
+ const messages = [
1314
+ ...compacted,
1315
+ { role: 'user', content: userContent },
1316
+ ];
1317
+ // Gen 27: model cascade for vision turns. Same-page non-error turns
1318
+ // use the cheap nav model (gpt-4.1-mini) since they're just following
1319
+ // instructions, not reasoning about new pages.
1320
+ const useNavModel = this.shouldUseNavigationModel(state, extraContext, turnInfo);
1321
+ const effectiveModel = useNavModel ? (this.navModelName || this.modelName) : this.modelName;
1322
+ const modelOpts = { provider: this.provider, model: effectiveModel };
1323
+ const nearingEnd = turnInfo && turnInfo.current >= turnInfo.max - 3;
1324
+ const maxTokens = nearingEnd ? 1200 : 600;
1325
+ // Gen 15: hybrid uses the unified prompt with both action vocabularies
1326
+ const systemPrompt = isHybrid ? UNIFIED_VISION_DOM_PROMPT : VISION_FIRST_PROMPT;
1327
+ const result = await this.generate(systemPrompt, messages, modelOpts, maxTokens);
1328
+ const raw = result.text;
1329
+ if (!raw) {
1330
+ throw new Error('Brain.decideVision: LLM returned empty response');
1331
+ }
1332
+ if (this.debug) {
1333
+ console.log('[Brain/Vision] Response:', raw.slice(0, 300));
1334
+ }
1335
+ const parsed = this.parse(raw);
1336
+ this.history.push({ role: 'user', content: userContent });
1337
+ this.history.push({ role: 'assistant', content: raw });
1338
+ const maxMessages = this.maxHistoryTurns * 2;
1339
+ if (this.history.length > maxMessages) {
1340
+ this.history = this.history.slice(-maxMessages);
1341
+ }
1342
+ return {
1343
+ ...parsed,
1344
+ raw,
1345
+ tokensUsed: result.tokensUsed,
1346
+ inputTokens: result.inputTokens,
1347
+ outputTokens: result.outputTokens,
1348
+ cacheReadInputTokens: result.cacheReadInputTokens,
1349
+ cacheCreationInputTokens: result.cacheCreationInputTokens,
1350
+ modelUsed: this.modelName,
1351
+ };
1352
+ }
1016
1353
  /**
1017
1354
  * Gen 7: ONE LLM call generates a structured plan for the entire task.
1018
1355
  *
@@ -1034,10 +1371,12 @@ ${visibleSnapshot}`;
1034
1371
  const startedAt = Date.now();
1035
1372
  const maxSteps = options?.maxSteps ?? 12;
1036
1373
  const extraContext = options?.extraContext;
1037
- // Snapshot budget for the planner: keep it conservative the planner
1038
- // prompt is itself substantial, and we want the LLM to focus on the
1039
- // STRUCTURE of the page rather than every leaf element.
1040
- const snapshot = budgetSnapshot(state.snapshot, 12_000);
1374
+ // Snapshot budget for the planner: Gen 10C raised from 12k to 24k. The
1375
+ // planner is the most important caller for extraction tasks because it
1376
+ // writes the runScript that runs on the first observation. Without enough
1377
+ // snapshot context (especially `<dl>/<dt>/<code>/<pre>` content lines that
1378
+ // Gen 10C now preserves), the planner emits selectors that don't exist.
1379
+ const snapshot = budgetSnapshot(state.snapshot, 24_000);
1041
1380
  const planSystemPrompt = `You are a planning engine for a browser automation agent.
1042
1381
 
1043
1382
  Given a user goal and the current page state, your job is to generate a complete, ordered plan of actions that the agent will execute deterministically without re-entering you between steps. After each step, the runner verifies your stated post-condition. If verification fails the runner falls back to a per-action loop, so your job is to write a plan that requires the FEWEST steps and where every step's post-condition is reliably observable.
@@ -1061,19 +1400,24 @@ KEY PRINCIPLES:
1061
1400
  7. EXTRACTION TASKS: when the goal asks you to READ, EXTRACT, REPORT, or RETURN values from the page (numbers, text, lists, structured data), the LAST step of your plan MUST be \`runScript\`. Do NOT emit a \`complete\` step after the runScript with literal values in \`result\`, because at planning time you cannot know what runScript will return — any values you write would be fabricated. The runner has a deterministic substitution path: it will use the runScript output as the final result, OR fall through to per-action mode where the LLM can see the script output. Either way is fine. The wrong move is to put placeholder JSON like \`{"x":null,"y":null}\` or \`"<from prior step>"\` in the complete result; the runner detects and replaces those, but it's cleaner if you simply omit the complete step. RIGHT: \`[{action:runScript, script:"..."}]\`. WRONG: \`[{action:runScript,...}, {action:complete, result:"{x:null}"}]\`.
1062
1401
 
1063
1402
  ACTION VERBS (same as the per-action prompt):
1064
- - {"action": "click", "selector": "@REF"}
1065
- - {"action": "type", "selector": "@REF", "text": "..."}
1403
+ - {"action": "click", "selector": "@REF"} — use when the element has a ref in ELEMENTS
1404
+ - {"action": "type", "selector": "@REF", "text": "..."} — type into a ref element
1066
1405
  - {"action": "press", "selector": "@REF", "key": "Enter"}
1067
1406
  - {"action": "select", "selector": "@REF", "value": "..."}
1407
+ - {"action": "clickAt", "x": 512, "y": 384} — click at pixel coordinates (use when you can see the element in the screenshot but it has no ref)
1408
+ - {"action": "typeAt", "x": 300, "y": 200, "text": "..."} — click at coordinates then type
1068
1409
  - {"action": "scroll", "direction": "up"|"down", "amount": 500}
1069
1410
  - {"action": "navigate", "url": "..."}
1070
1411
  - {"action": "wait", "ms": 1000}
1071
1412
  - {"action": "fill", "fields": {"@a": "v1", "@b": "v2"}, "selects": {"@c": "v3"}, "checks": ["@d", "@e"]}
1072
1413
  - {"action": "clickSequence", "refs": ["@a", "@b", "@c"]}
1073
1414
  - {"action": "runScript", "script": "document.querySelector('.x').textContent"}
1415
+ - {"action": "extractWithIndex", "query": "p, span, dd, code", "contains": "downloads"} — return a NUMBERED list of visible elements matching the query with their full textContent. Use this for extraction tasks where the data lives in obscurely-classed wrappers (npm download counts, MDN \`<dl>/<dt>/<dd>\` content, Python docs \`<code>\` blocks, W3C spec content) and the planner cannot guarantee a precise selector. The next step (in plan or per-action mode) reads the result and picks the right index. STRONGLY PREFER THIS OVER runScript on ANY extraction task where the snapshot doesn't already show the value verbatim.
1074
1416
  - {"action": "complete", "result": "..."}
1075
1417
  - {"action": "abort", "reason": "..."}
1076
1418
 
1419
+ ${URL_FIRST_RULES}
1420
+
1077
1421
  RESPONSE FORMAT — respond with ONLY this JSON:
1078
1422
  {
1079
1423
  "reasoning": "1-2 sentence strategy summary",
@@ -1102,7 +1446,22 @@ ELEMENTS:
1102
1446
  ${snapshot}
1103
1447
  ${extraContext ? `\n${extraContext}\n` : ''}
1104
1448
  What is the complete plan?`;
1105
- const result = await this.generate(planSystemPrompt, [{ role: 'user', content: userText }], { provider: this.provider, model: this.modelName },
1449
+ // Gen 20: vision-aware planner. When hybrid mode is active AND a
1450
+ // screenshot is available, send the screenshot alongside the DOM so
1451
+ // the planner can see the visual layout. This helps on sites like
1452
+ // Google Flights where the DOM doesn't convey form structure well.
1453
+ const isVisionPlanner = (this.observationMode === 'hybrid' || this.observationMode === 'vision') && !!state.screenshot;
1454
+ const userContent = isVisionPlanner
1455
+ ? [
1456
+ { type: 'text', text: userText },
1457
+ { type: 'image', image: state.screenshot, mediaType: 'image/jpeg' },
1458
+ ]
1459
+ : userText;
1460
+ // Gen 28: planner can use a different model (e.g., Claude Opus for reasoning)
1461
+ const planModelOpts = this.plannerModel
1462
+ ? { provider: (this.plannerProvider || this.provider), model: this.plannerModel }
1463
+ : { provider: this.provider, model: this.modelName };
1464
+ const result = await this.generate(planSystemPrompt, [{ role: 'user', content: userContent }], planModelOpts,
1106
1465
  // Plans need more output tokens than decide() — a 10-step plan with
1107
1466
  // batch fills + rationale per step is comfortably over 1000 tokens.
1108
1467
  2_500).catch((err) => ({
@@ -1358,13 +1717,12 @@ ${budgetSnapshot(state.snapshot)}${siteBoundaryNote ? `\n\n${siteBoundaryNote}`
1358
1717
 
1359
1718
  Was the goal actually achieved? Analyze the current page state carefully.`;
1360
1719
  const userContent = this.buildUserContent(textContent, state.screenshot, true);
1361
- // Verification is a structured yes/no task use nav model if available
1362
- const verifyProvider = this.adaptiveModelRouting && this.navModelName
1363
- ? (this.navProvider || this.provider)
1364
- : undefined;
1365
- const verifyModel = this.adaptiveModelRouting && this.navModelName
1366
- ? this.navModelName
1367
- : undefined;
1720
+ // Gen 28: verifier can use its own model, falls back to nav model, then main
1721
+ const verifyProvider = this.verifierProvider
1722
+ ? this.verifierProvider
1723
+ : (this.adaptiveModelRouting && this.navModelName ? (this.navProvider || this.provider) : undefined);
1724
+ const verifyModel = this.verifierModel
1725
+ || (this.adaptiveModelRouting && this.navModelName ? this.navModelName : undefined);
1368
1726
  const result = await this.generate(`Verify whether the browser agent achieved its goal. Respond with ONLY JSON:
1369
1727
  {"achieved":true,"confidence":0.9,"evidence":["observation"],"missing":[]}
1370
1728
 
@@ -1546,8 +1904,11 @@ Only include facts that are genuinely useful. Quality over quantity. Max 10 fact
1546
1904
  const VALID_ACTIONS = new Set([
1547
1905
  'click', 'type', 'press', 'hover', 'select',
1548
1906
  'scroll', 'navigate', 'wait', 'evaluate', 'runScript',
1907
+ 'extractWithIndex',
1549
1908
  'verifyPreview', 'complete', 'abort',
1550
1909
  'fill', 'clickSequence',
1910
+ 'clickAt', 'typeAt',
1911
+ 'clickLabel', 'typeLabel',
1551
1912
  ]);
1552
1913
  try {
1553
1914
  const parsed = JSON.parse(text);
@@ -1602,6 +1963,26 @@ function deduplicateSnapshot(snapshot) {
1602
1963
  // Strip trailing numbers/ordinals to get a "name stem" for grouping.
1603
1964
  // "Page 1" and "Page 20" → "Page ", "Item #3" and "Item #42" → "Item #"
1604
1965
  const nameStem = (name) => name.replace(/\d+/g, '#');
1966
+ // Structural fingerprint for a block of lines (element + its children).
1967
+ // Used for card-level dedup: two hotel cards have different names but the
1968
+ // same structure (listitem > link + img + text + text + button).
1969
+ const structuralFingerprint = (startIdx, baseIndent) => {
1970
+ const roles = [];
1971
+ let j = startIdx;
1972
+ while (j < lines.length) {
1973
+ const p = parseLine(lines[j]);
1974
+ if (!p) {
1975
+ j++;
1976
+ continue;
1977
+ }
1978
+ // Stop when we hit an element at the same or shallower indent (sibling or parent)
1979
+ if (j > startIdx && p.indent.length <= baseIndent.length)
1980
+ break;
1981
+ roles.push(p.role);
1982
+ j++;
1983
+ }
1984
+ return { fp: roles.join(','), endIdx: j };
1985
+ };
1605
1986
  let i = 0;
1606
1987
  while (i < lines.length) {
1607
1988
  const parsed = parseLine(lines[i]);
@@ -1611,7 +1992,41 @@ function deduplicateSnapshot(snapshot) {
1611
1992
  i++;
1612
1993
  continue;
1613
1994
  }
1614
- // Collect a consecutive run of same (indent, role) with similar name stems
1995
+ // Try block-level dedup first: look for consecutive sibling blocks
1996
+ // with the same structural fingerprint (same child-role sequence).
1997
+ // This catches card patterns like Booking hotel results, Allrecipes cards.
1998
+ if (/\b(?:listitem|article|group|region)\b/i.test(parsed.role)) {
1999
+ const { fp: firstFp, endIdx: firstEnd } = structuralFingerprint(i, parsed.indent);
2000
+ if (firstEnd > i + 2 && firstFp.includes(',')) { // non-trivial block
2001
+ const blocks = [
2002
+ { start: i, end: firstEnd, firstLine: lines[i] },
2003
+ ];
2004
+ let scanIdx = firstEnd;
2005
+ while (scanIdx < lines.length) {
2006
+ const nextParsed = parseLine(lines[scanIdx]);
2007
+ if (!nextParsed || nextParsed.indent !== parsed.indent || nextParsed.role !== parsed.role)
2008
+ break;
2009
+ const { fp: nextFp, endIdx: nextEnd } = structuralFingerprint(scanIdx, nextParsed.indent);
2010
+ if (nextFp !== firstFp)
2011
+ break;
2012
+ blocks.push({ start: scanIdx, end: nextEnd, firstLine: lines[scanIdx] });
2013
+ scanIdx = nextEnd;
2014
+ }
2015
+ if (blocks.length >= 3) {
2016
+ // Emit first 2 blocks fully, summarize the rest
2017
+ for (let b = 0; b < Math.min(2, blocks.length); b++) {
2018
+ for (let k = blocks[b].start; k < blocks[b].end; k++) {
2019
+ out.push(lines[k]);
2020
+ }
2021
+ }
2022
+ const remaining = blocks.length - 2;
2023
+ out.push(`${parsed.indent}... [${remaining} more similar ${parsed.role} blocks with same structure]`);
2024
+ i = blocks[blocks.length - 1].end;
2025
+ continue;
2026
+ }
2027
+ }
2028
+ }
2029
+ // Line-level dedup: consecutive runs of same (indent, role, name stem)
1615
2030
  const group = [parsed];
1616
2031
  const stem = nameStem(parsed.name);
1617
2032
  let j = i + 1;
@@ -1640,39 +2055,53 @@ function deduplicateSnapshot(snapshot) {
1640
2055
  return out.join('\n');
1641
2056
  }
1642
2057
  /**
1643
- * Cap snapshot size for non-first turns to control token cost on large pages.
1644
- * Keeps the full snapshot when it fits within budget; otherwise truncates
1645
- * non-interactive decorative lines first, then hard-caps with a notice.
2058
+ * Cap snapshot size to control token cost on large pages.
2059
+ * Keeps the full snapshot when it fits within budget; otherwise preserves:
2060
+ * 1. Interactive elements with refs (buttons, inputs, links for action targets)
2061
+ * 2. Content lines: term/definition/code/pre/paragraph (for extraction tasks
2062
+ * like MDN, Python docs, W3C spec where the value the agent needs lives in
2063
+ * a `<dl>/<code>/<pre>` block, not in an interactive element)
2064
+ *
2065
+ * Gen 10C raised the default from 16k to 24k to give planners on extraction
2066
+ * pages more room before truncation kicks in. The 8k same-page budget remains
2067
+ * unchanged because by then the LLM has already seen the full snapshot once.
1646
2068
  */
1647
- function budgetSnapshot(snapshot, maxChars = 16_000) {
2069
+ export function budgetSnapshot(snapshot, maxChars = 24_000) {
1648
2070
  // Skip dedup on small snapshots — not enough repetition to justify the O(n) scan
1649
2071
  if (snapshot.length > 6_000) {
1650
2072
  snapshot = deduplicateSnapshot(snapshot);
1651
2073
  }
1652
2074
  if (snapshot.length <= maxChars)
1653
2075
  return snapshot;
1654
- // First pass: drop non-interactive lines (images, paragraphs, decorative text)
1655
- // to keep interactive elements (buttons, links, textboxes, headings).
2076
+ // First pass: separate keep-set (interactive + content lines) from decorative.
2077
+ // Content roles (term, definition, code, pre, paragraph) carry text the LLM
2078
+ // needs for extraction tasks. They have no [ref=] but the text is the data.
1656
2079
  const lines = snapshot.split('\n');
1657
2080
  const interactive = [];
2081
+ const content = [];
1658
2082
  const decorative = [];
1659
2083
  for (const line of lines) {
1660
2084
  if (/\b(?:button|link|textbox|combobox|menuitem|checkbox|radio|select|heading|dialog|alertdialog)\b/i.test(line) && /\[ref=/.test(line)) {
1661
2085
  interactive.push(line);
1662
2086
  }
2087
+ else if (/^\s*-\s+(?:term|definition|code|pre|paragraph)\b/i.test(line)) {
2088
+ content.push(line);
2089
+ }
1663
2090
  else {
1664
2091
  decorative.push(line);
1665
2092
  }
1666
2093
  }
1667
- // If interactive-only fits, use it with a truncation note
1668
- const interactiveText = interactive.join('\n');
1669
- if (interactiveText.length <= maxChars) {
1670
- return interactiveText + `\n... [${decorative.length} decorative elements omitted for brevity]`;
2094
+ // If interactive + content fits, use both with a truncation note
2095
+ const keepSet = interactive.concat(content);
2096
+ const keepText = keepSet.join('\n');
2097
+ if (keepText.length <= maxChars) {
2098
+ return keepText + `\n... [${decorative.length} decorative elements omitted for brevity]`;
1671
2099
  }
1672
- // Second pass: when interactive elements still exceed budget, prioritize:
1673
- // 1. searchbox/textbox/combobox (inputs — essential for form tasks)
1674
- // 2. headings (structural navigation)
1675
- // 3. links/buttons (main content keep all, trim from end as last resort)
2100
+ // Second pass: when interactive + content still exceed budget, prioritize:
2101
+ // 1. inputs (searchbox/textbox/combobox) — essential for form tasks
2102
+ // 2. headings + dialogs — structural navigation
2103
+ // 3. content lines (term/definition/code/pre)extraction data
2104
+ // 4. bulk links/buttons — main content
1676
2105
  const priority = [];
1677
2106
  const bulk = [];
1678
2107
  for (const line of interactive) {
@@ -1683,7 +2112,10 @@ function budgetSnapshot(snapshot, maxChars = 16_000) {
1683
2112
  bulk.push(line);
1684
2113
  }
1685
2114
  }
1686
- const priorityText = priority.join('\n');
2115
+ // Content lines come right after priority interactive (they're the extraction
2116
+ // data) and before bulk links/buttons.
2117
+ const priorityWithContent = priority.concat(content);
2118
+ const priorityText = priorityWithContent.join('\n');
1687
2119
  const remaining = maxChars - priorityText.length - 80; // reserve space for note
1688
2120
  if (remaining > 0) {
1689
2121
  const bulkText = bulk.join('\n');
@@ -1768,6 +2200,15 @@ function validateAction(actionType, data) {
1768
2200
  return { action: 'evaluate', criteria: optStr('criteria') };
1769
2201
  case 'runScript':
1770
2202
  return { action: 'runScript', script: requireStr('script') };
2203
+ case 'extractWithIndex': {
2204
+ const query = requireStr('query');
2205
+ const contains = typeof data.contains === 'string' ? data.contains : '';
2206
+ return {
2207
+ action: 'extractWithIndex',
2208
+ query,
2209
+ ...(contains ? { contains } : {}),
2210
+ };
2211
+ }
1771
2212
  case 'verifyPreview':
1772
2213
  return { action: 'verifyPreview' };
1773
2214
  case 'complete':
@@ -1807,6 +2248,16 @@ function validateAction(actionType, data) {
1807
2248
  ...(typeof data.intervalMs === 'number' ? { intervalMs: data.intervalMs } : {}),
1808
2249
  };
1809
2250
  }
2251
+ // Gen 13: Vision-first coordinate actions
2252
+ case 'clickAt':
2253
+ return { action: 'clickAt', x: num(data.x, 0), y: num(data.y, 0) };
2254
+ case 'typeAt':
2255
+ return { action: 'typeAt', x: num(data.x, 0), y: num(data.y, 0), text: optStr('text') };
2256
+ // Gen 23: SoM label-based actions
2257
+ case 'clickLabel':
2258
+ return { action: 'clickLabel', label: num(data.label, 0) };
2259
+ case 'typeLabel':
2260
+ return { action: 'typeLabel', label: num(data.label, 0), text: optStr('text') };
1810
2261
  default:
1811
2262
  throw new Error(`Unknown action type: ${actionType}`);
1812
2263
  }