@tangle-network/browser-agent-driver 0.7.0 → 0.8.7

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 (46) hide show
  1. package/README.md +5 -13
  2. package/dist/brain/index.d.ts +25 -8
  3. package/dist/brain/index.d.ts.map +1 -1
  4. package/dist/brain/index.js +244 -96
  5. package/dist/brain/index.js.map +1 -1
  6. package/dist/captcha.d.ts +55 -0
  7. package/dist/captcha.d.ts.map +1 -0
  8. package/dist/captcha.js +403 -0
  9. package/dist/captcha.js.map +1 -0
  10. package/dist/cli-design-audit.d.ts +59 -0
  11. package/dist/cli-design-audit.d.ts.map +1 -0
  12. package/dist/cli-design-audit.js +1245 -0
  13. package/dist/cli-design-audit.js.map +1 -0
  14. package/dist/cli-ui.d.ts +49 -0
  15. package/dist/cli-ui.d.ts.map +1 -0
  16. package/dist/cli-ui.js +383 -0
  17. package/dist/cli-ui.js.map +1 -0
  18. package/dist/cli.js +76 -169
  19. package/dist/cli.js.map +1 -1
  20. package/dist/drivers/cdp-snapshot.d.ts.map +1 -1
  21. package/dist/drivers/cdp-snapshot.js +2 -1
  22. package/dist/drivers/cdp-snapshot.js.map +1 -1
  23. package/dist/drivers/playwright.d.ts +6 -0
  24. package/dist/drivers/playwright.d.ts.map +1 -1
  25. package/dist/drivers/playwright.js +34 -9
  26. package/dist/drivers/playwright.js.map +1 -1
  27. package/dist/drivers/snapshot.d.ts +1 -0
  28. package/dist/drivers/snapshot.d.ts.map +1 -1
  29. package/dist/drivers/snapshot.js +12 -5
  30. package/dist/drivers/snapshot.js.map +1 -1
  31. package/dist/drivers/types.d.ts +2 -0
  32. package/dist/drivers/types.d.ts.map +1 -1
  33. package/dist/index.d.ts +5 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +4 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/recovery.d.ts.map +1 -1
  38. package/dist/recovery.js +16 -35
  39. package/dist/recovery.js.map +1 -1
  40. package/dist/runner/runner.d.ts +1 -0
  41. package/dist/runner/runner.d.ts.map +1 -1
  42. package/dist/runner/runner.js +133 -98
  43. package/dist/runner/runner.js.map +1 -1
  44. package/dist/types.d.ts +124 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +4 -2
package/README.md CHANGED
@@ -9,26 +9,18 @@ LLM-driven browser automation. Reads page state via accessibility tree, decides
9
9
  ### CLI
10
10
 
11
11
  ```bash
12
- # global install gives you the `bad` command
13
- npm i -g @tangle-network/browser-agent-driver
14
- npx playwright install chromium
15
-
16
- # or run without installing
17
- npx @tangle-network/browser-agent-driver run --goal "..." --url https://...
12
+ curl -fsSL https://raw.githubusercontent.com/tangle-network/browser-agent-driver/main/scripts/install.sh | sh
18
13
  ```
19
14
 
20
- ### Standalone (no npm)
15
+ Installs the `bad` command to `~/.local/bin`, downloads Playwright Chromium, and adds PATH instructions. Requires Node.js 20+.
16
+
17
+ Or via npm:
21
18
 
22
19
  ```bash
23
- # download, extract, add to PATH
24
- curl -fsSL https://github.com/tangle-network/browser-agent-driver/releases/latest/download/bad-v0.7.0-node.tar.gz | tar xz
25
- export PATH="$PWD/bad-v0.7.0:$PATH"
20
+ npm i -g @tangle-network/browser-agent-driver
26
21
  npx playwright install chromium
27
- bad run --goal "..." --url https://...
28
22
  ```
29
23
 
30
- Requires Node.js 20+. See [Releases](https://github.com/tangle-network/browser-agent-driver/releases) for all versions.
31
-
32
24
  ### As a library
33
25
 
34
26
  ```bash
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Uses Vercel AI SDK for multi-provider support (OpenAI, Anthropic, Google, Codex CLI, Claude Code).
6
6
  */
7
- import type { ModelMessage } from 'ai';
7
+ import type { ModelMessage, LanguageModel } from 'ai';
8
8
  import type { Action, PageState, AgentConfig, DesignFinding, GoalVerification } from '../types.js';
9
9
  export interface BrainDecision {
10
10
  action: Action;
@@ -64,6 +64,11 @@ export declare class Brain {
64
64
  private resolveModelName;
65
65
  private shouldSendTemperature;
66
66
  private generationOptions;
67
+ /** Get a LLM model instance, optionally with provider/model override (e.g. for CAPTCHA fallback) */
68
+ getLanguageModel(selection?: {
69
+ provider?: 'openai' | 'anthropic' | 'google';
70
+ model?: string;
71
+ }): Promise<LanguageModel>;
67
72
  /** Lazily create the LLM model instance based on provider config */
68
73
  private getModel;
69
74
  private generate;
@@ -78,6 +83,12 @@ export declare class Brain {
78
83
  * not for decide(). The flag is kept for future experiments with better routing signals.
79
84
  */
80
85
  private shouldUseNavigationModel;
86
+ /**
87
+ * Build the system prompt dynamically, injecting conditional rule groups
88
+ * based on goal text, page snapshot content, and turn number.
89
+ * Saves ~800 tokens per turn on simple navigation tasks.
90
+ */
91
+ private buildSystemPrompt;
81
92
  /** Reset conversation history (call between scenarios) */
82
93
  reset(): void;
83
94
  /** Get current conversation history */
@@ -91,15 +102,21 @@ export declare class Brain {
91
102
  private buildUserContent;
92
103
  /**
93
104
  * Compact conversation history: strip ELEMENTS blocks and screenshots
94
- * from all but the most recent observation.
105
+ * from older observations, keeping the last 2 user messages intact.
95
106
  *
96
- * Note: Aggressive one-line compression was tested (2026-03-08) and found
97
- * counterproductive the agent loses context about visited pages and
98
- * wastes turns revisiting them. The current approach (strip snapshots,
99
- * keep full text) is the empirically best balance.
107
+ * For older turns, replaces the full ELEMENTS block with a one-line
108
+ * summary showing element count and the selectors the agent actually
109
+ * used, extracted from the paired assistant response.
100
110
  */
101
111
  private compactHistory;
102
- private stripElements;
112
+ /**
113
+ * Extract @ref selectors from an assistant JSON response.
114
+ */
115
+ private extractSelectorsFromResponse;
116
+ /**
117
+ * Replace the ELEMENTS block with a one-line action-only summary.
118
+ */
119
+ private summarizeElements;
103
120
  decide(goal: string, state: PageState, extraContext?: string, turnInfo?: {
104
121
  current: number;
105
122
  max: number;
@@ -128,7 +145,7 @@ export declare class Brain {
128
145
  * Uses vision to analyze layout, typography, spacing, contrast, and UX.
129
146
  * Returns structured findings with categories and severity levels.
130
147
  */
131
- auditDesign(state: PageState, goal: string, checkpoints: string[]): Promise<{
148
+ auditDesign(state: PageState, goal: string, checkpoints: string[], systemPrompt?: string): Promise<{
132
149
  score: number;
133
150
  findings: DesignFinding[];
134
151
  raw: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/brain/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/brain/index.ts"],"names":[],"mappings":"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"}
@@ -9,7 +9,8 @@ import { AriaSnapshotHelper } from '../drivers/snapshot.js';
9
9
  import { resolveProviderApiKey, resolveProviderModelName } from '../provider-defaults.js';
10
10
  import { buildFirstPartyBoundaryNote } from '../domain-policy.js';
11
11
  import { generateWithSandboxBackend } from '../providers/sandbox-backend.js';
12
- const SYSTEM_PROMPT = `You are a senior staff engineer operating a browser via Playwright automation.
12
+ /** Core system prompt: preamble, actions, format, and rules 1-14 (always sent) */
13
+ const CORE_RULES = `You are a senior staff engineer operating a browser via Playwright automation.
13
14
 
14
15
  You can SEE the page (via screenshot) and READ the page structure (via accessibility tree with @ref IDs).
15
16
  Use BOTH inputs together — the screenshot shows layout/design/visual state, the a11y tree shows interactive elements with refs.
@@ -59,17 +60,25 @@ RULES:
59
60
  11. After the app builds and a preview is visible, use "verifyPreview" to check for errors before completing
60
61
  12. BLOCKER-FIRST POLICY: if a modal, limit, quota, permission, or error dialog blocks progress, resolve THAT first before continuing the main goal
61
62
  13. For quota/limit blockers, use an unblock ladder: open manage path -> clean up old test resources if needed -> retry the original action
62
- 14. If the same action triggers the same blocker twice, switch strategy immediately (different button/path), do not repeat blind retries
63
+ 14. If the same action triggers the same blocker twice, switch strategy immediately (different button/path), do not repeat blind retries`;
64
+ /** Search-related rules (15-17): injected when page has search elements or /search URL */
65
+ const SEARCH_RULES = `
63
66
  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
64
67
  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
65
- 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
68
+ 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`;
69
+ /** Data extraction rules (18, 21-23): injected when goal involves extracting data */
70
+ const DATA_EXTRACTION_RULES = `
66
71
  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
67
- 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
68
- 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
69
72
  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
70
73
  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
71
- 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
72
- 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
74
+ 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`;
75
+ /** Heavy page rules (19-20, 24): injected when snapshot is large or turn count is high */
76
+ const HEAVY_PAGE_RULES = `
77
+ 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
78
+ 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
79
+ 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`;
80
+ /** Reasoning framework and examples (always appended after rules) */
81
+ const REASONING_SUFFIX = `
73
82
 
74
83
  REASONING FRAMEWORK — before choosing an action:
75
84
  1. What is the current state vs. the goal state? What is missing?
@@ -83,6 +92,12 @@ EXAMPLE 1 — Multi-step form fill (use actual refs from ELEMENTS, not these pla
83
92
 
84
93
  EXAMPLE 2 — Recovery after failure:
85
94
  {"plan":["Click the send button","Wait for response"],"currentStep":0,"action":{"action":"scroll","direction":"down","amount":300},"reasoning":"My last click failed because the element was not visible in the viewport. I can see from the screenshot that the send button is below the fold. Scrolling down to bring it into view before retrying.","expectedEffect":"The send button should become visible in the viewport"}`;
95
+ /** Full static prompt (all rules) — used as default when config.systemPrompt is not set */
96
+ const SYSTEM_PROMPT = CORE_RULES + SEARCH_RULES + DATA_EXTRACTION_RULES + HEAVY_PAGE_RULES + REASONING_SUFFIX;
97
+ /** Pattern for detecting data-extraction keywords in goal text */
98
+ const DATA_EXTRACTION_PATTERN = /\b(extract|list|find|data|price|pric|names?|rating|cost|count)\b/i;
99
+ /** Pattern for detecting search-related roles in snapshot text */
100
+ const SEARCH_SNAPSHOT_PATTERN = /^\s*-\s+(?:searchbox|combobox)\s/m;
86
101
  const FIRST_TURN_COMPACT_PROMPT = `You are a browser agent choosing the fastest safe next action.
87
102
 
88
103
  Return ONLY valid JSON with:
@@ -102,35 +117,9 @@ Rules:
102
117
  4. If a blocker is visible, resolve it first.
103
118
  5. Do not over-explore on the first turn.
104
119
  6. Respond with JSON only.`;
105
- const LINK_SCOUT_PROMPT = `You are a browser navigation scout.
106
-
107
- Your job is NOT to browse freely. Your only job is to pick the best next visible link from a short candidate list.
108
-
109
- You will receive:
110
- - the user goal
111
- - the current URL/title
112
- - the current page structure
113
- - a small ranked candidate list of visible links
114
-
115
- Choose the single best candidate that most directly advances the goal.
116
- Prefer:
117
- - first-party links already visible on the current page
118
- - links whose text matches the requested entity/content type
119
- - links that avoid unnecessary search detours
120
-
121
- Respond with ONLY a JSON object:
122
- {
123
- "selector": "@ref",
124
- "reasoning": "brief reason",
125
- "confidence": 0.82
126
- }
127
-
128
- Rules:
129
- 1. selector must exactly match one candidate ref
130
- 2. choose only one candidate
131
- 3. do not invent refs
132
- 4. confidence must be 0 to 1
133
- 5. if none are viable, choose the best available candidate anyway`;
120
+ const LINK_SCOUT_PROMPT = `Pick the best link from CANDIDATES to advance the GOAL. Respond with ONLY JSON:
121
+ {"selector":"@ref","reasoning":"brief reason","confidence":0.82}
122
+ Rules: use exact candidate ref, pick one, confidence 0-1, prefer first-party and text-matching links.`;
134
123
  const DESIGN_AUDIT_PROMPT = `You are a senior product designer and UX engineer auditing a web application.
135
124
 
136
125
  Analyze the screenshot and accessibility tree for design quality, UX issues, and visual bugs.
@@ -253,6 +242,10 @@ export class Brain {
253
242
  : { maxOutputTokens }),
254
243
  };
255
244
  }
245
+ /** Get a LLM model instance, optionally with provider/model override (e.g. for CAPTCHA fallback) */
246
+ async getLanguageModel(selection) {
247
+ return this.getModel(selection);
248
+ }
256
249
  /** Lazily create the LLM model instance based on provider config */
257
250
  async getModel(selection) {
258
251
  const providerName = selection?.provider || this.provider;
@@ -384,6 +377,32 @@ export class Brain {
384
377
  // Verification still routes to nav model (separate code path).
385
378
  return false;
386
379
  }
380
+ /**
381
+ * Build the system prompt dynamically, injecting conditional rule groups
382
+ * based on goal text, page snapshot content, and turn number.
383
+ * Saves ~800 tokens per turn on simple navigation tasks.
384
+ */
385
+ buildSystemPrompt(goal, state, turn) {
386
+ // If a custom systemPrompt was set via config, use it verbatim
387
+ if (this.systemPrompt !== SYSTEM_PROMPT)
388
+ return this.systemPrompt;
389
+ let prompt = CORE_RULES;
390
+ // Search rules: page has searchbox/combobox roles or URL contains /search
391
+ const snapshotSample = state.snapshot.length > 4000 ? state.snapshot.slice(0, 4000) : state.snapshot;
392
+ if (SEARCH_SNAPSHOT_PATTERN.test(snapshotSample) || /\/search\b/i.test(state.url)) {
393
+ prompt += SEARCH_RULES;
394
+ }
395
+ // Data extraction rules: goal mentions extraction-related keywords
396
+ if (DATA_EXTRACTION_PATTERN.test(goal)) {
397
+ prompt += DATA_EXTRACTION_RULES;
398
+ }
399
+ // Heavy page rules: large snapshot or late in the run
400
+ if (state.snapshot.length > 10_000 || turn > 10) {
401
+ prompt += HEAVY_PAGE_RULES;
402
+ }
403
+ prompt += REASONING_SUFFIX;
404
+ return prompt;
405
+ }
387
406
  /** Reset conversation history (call between scenarios) */
388
407
  reset() {
389
408
  this.history = [];
@@ -417,62 +436,135 @@ export class Brain {
417
436
  }
418
437
  /**
419
438
  * Compact conversation history: strip ELEMENTS blocks and screenshots
420
- * from all but the most recent observation.
439
+ * from older observations, keeping the last 2 user messages intact.
421
440
  *
422
- * Note: Aggressive one-line compression was tested (2026-03-08) and found
423
- * counterproductive the agent loses context about visited pages and
424
- * wastes turns revisiting them. The current approach (strip snapshots,
425
- * keep full text) is the empirically best balance.
441
+ * For older turns, replaces the full ELEMENTS block with a one-line
442
+ * summary showing element count and the selectors the agent actually
443
+ * used, extracted from the paired assistant response.
426
444
  */
427
445
  compactHistory() {
428
446
  if (this.history.length === 0)
429
447
  return [];
448
+ // Find indices of the last 2 user messages to keep intact
449
+ const userIndices = [];
450
+ for (let i = this.history.length - 1; i >= 0; i--) {
451
+ if (this.history[i].role === 'user') {
452
+ userIndices.push(i);
453
+ if (userIndices.length === 2)
454
+ break;
455
+ }
456
+ }
457
+ const keepIntactFrom = userIndices.length > 0
458
+ ? userIndices[userIndices.length - 1]
459
+ : this.history.length;
430
460
  return this.history.map((msg, idx) => {
431
461
  if (msg.role !== 'user')
432
462
  return msg;
433
- // Keep the last user message intact
434
- if (idx >= this.history.length - 2)
463
+ // Keep the last 2 user messages intact (full snapshot)
464
+ if (idx >= keepIntactFrom)
435
465
  return msg;
466
+ // For older user messages, extract selectors from paired assistant response
467
+ const assistantMsg = idx + 1 < this.history.length ? this.history[idx + 1] : undefined;
468
+ const selectors = assistantMsg?.role === 'assistant'
469
+ ? this.extractSelectorsFromResponse(typeof assistantMsg.content === 'string' ? assistantMsg.content : '')
470
+ : [];
436
471
  // Handle multimodal content (array of parts)
437
472
  if (Array.isArray(msg.content)) {
438
473
  const compacted = msg.content
439
- // Keep only text parts (strip screenshots from old messages)
440
474
  .filter((part) => part.type === 'text')
441
475
  .map((part) => ({
442
476
  ...part,
443
- text: this.stripElements(part.text),
477
+ text: this.summarizeElements(part.text, selectors),
444
478
  }));
445
479
  return { ...msg, content: compacted };
446
480
  }
447
481
  // Handle string content
448
482
  if (typeof msg.content === 'string') {
449
- return { ...msg, content: this.stripElements(msg.content) };
483
+ return { ...msg, content: this.summarizeElements(msg.content, selectors) };
450
484
  }
451
485
  return msg;
452
486
  });
453
487
  }
454
- stripElements(text) {
455
- return text.replace(/ELEMENTS:\n[\s\S]*?(?=\n\n|What action should you take\?|$)/, (_match) => {
456
- // Extract the snapshot text from the ELEMENTS block
457
- const snapshotStart = _match.indexOf('\n');
488
+ /**
489
+ * Extract @ref selectors from an assistant JSON response.
490
+ */
491
+ extractSelectorsFromResponse(raw) {
492
+ const selectors = [];
493
+ try {
494
+ let text = raw.trim();
495
+ if (text.startsWith('```')) {
496
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
497
+ }
498
+ const parsed = JSON.parse(text);
499
+ if (parsed.action?.selector)
500
+ selectors.push(parsed.action.selector);
501
+ if (Array.isArray(parsed.nextActions)) {
502
+ for (const na of parsed.nextActions) {
503
+ if (na?.selector)
504
+ selectors.push(na.selector);
505
+ }
506
+ }
507
+ }
508
+ catch {
509
+ // Best effort
510
+ }
511
+ return selectors;
512
+ }
513
+ /**
514
+ * Replace the ELEMENTS block with a one-line action-only summary.
515
+ */
516
+ summarizeElements(text, selectors) {
517
+ return text.replace(/ELEMENTS[^:\n]*:\n[\s\S]*?(?=\n\n|What action should you take\?|$)/, (match) => {
518
+ const snapshotStart = match.indexOf('\n');
458
519
  if (snapshotStart === -1)
459
520
  return 'ELEMENTS:\n[previous snapshot]';
460
- const snapshotText = _match.slice(snapshotStart + 1);
461
- const compact = AriaSnapshotHelper.formatCompact(snapshotText);
462
- if (compact.length > 0) {
463
- return `ELEMENTS (compact):\n${compact}`;
464
- }
465
- return 'ELEMENTS:\n[previous snapshot]';
521
+ const snapshotText = match.slice(snapshotStart + 1);
522
+ const elementCount = (snapshotText.match(/\[ref=\w+\]/g) || []).length;
523
+ const selectorList = selectors.length > 0
524
+ ? selectors.join(', ')
525
+ : 'none';
526
+ return `ELEMENTS:\n[Page snapshot: ${elementCount} elements | agent used: ${selectorList}]`;
466
527
  });
467
528
  }
468
529
  async decide(goal, state, extraContext, turnInfo, options) {
469
530
  const useCompactFirstTurn = this.compactFirstTurn && turnInfo?.current === 1;
470
531
  const samePageAsPrevious = this.lastDecisionUrl === state.url;
532
+ const isFirstTurn = !turnInfo || turnInfo.current <= 1;
533
+ // Diff-only mode: on same-page turns with small diffs, send only changed
534
+ // elements instead of the full snapshot. Saves 40-80% of input tokens on
535
+ // form-fill / interaction-heavy pages where the page structure is stable.
536
+ const rawDiff = state.snapshotDiffRaw;
537
+ const diffChanges = rawDiff ? rawDiff.added.length + rawDiff.removed.length + rawDiff.changed.length : 0;
538
+ const diffTotal = rawDiff ? diffChanges + rawDiff.unchangedCount : 0;
539
+ const useDiffOnly = samePageAsPrevious
540
+ && !isFirstTurn
541
+ && rawDiff !== undefined
542
+ && diffChanges > 0
543
+ && diffTotal > 0
544
+ && diffChanges / diffTotal < 0.3;
471
545
  // Tighter snapshot budget on same-page turns — agent already saw the full page
472
546
  const snapshotBudget = samePageAsPrevious ? 8_000 : 16_000;
473
- const visibleSnapshot = useCompactFirstTurn
474
- ? compactFirstTurnSnapshot(state.snapshot)
475
- : budgetSnapshot(state.snapshot, snapshotBudget);
547
+ let visibleSnapshot;
548
+ let elementsHeader;
549
+ if (useDiffOnly) {
550
+ // Build compact diff-only view: changed/added elements with refs
551
+ const lines = [];
552
+ if (rawDiff.added.length)
553
+ lines.push('ADDED:', ...rawDiff.added);
554
+ if (rawDiff.changed.length)
555
+ lines.push('CHANGED:', ...rawDiff.changed);
556
+ if (rawDiff.removed.length)
557
+ lines.push('REMOVED:', ...rawDiff.removed);
558
+ lines.push(`(${rawDiff.unchangedCount} elements unchanged — refs from previous turn still valid)`);
559
+ visibleSnapshot = lines.join('\n');
560
+ elementsHeader = 'ELEMENTS (diff-only, previous refs still valid)';
561
+ }
562
+ else {
563
+ visibleSnapshot = useCompactFirstTurn
564
+ ? compactFirstTurnSnapshot(state.snapshot)
565
+ : budgetSnapshot(state.snapshot, snapshotBudget);
566
+ elementsHeader = 'ELEMENTS';
567
+ }
476
568
  this.lastDecisionUrl = state.url;
477
569
  // Build user message with stable prefix (GOAL) for prompt caching,
478
570
  // then dynamic per-turn content (turn budget, page state, elements).
@@ -482,7 +574,7 @@ CURRENT PAGE:
482
574
  URL: ${state.url}
483
575
  Title: ${state.title}
484
576
 
485
- ELEMENTS:
577
+ ${elementsHeader}:
486
578
  ${visibleSnapshot}`;
487
579
  if (turnInfo) {
488
580
  const remaining = turnInfo.max - turnInfo.current;
@@ -498,8 +590,8 @@ ${visibleSnapshot}`;
498
590
  textContent += ` — HALF BUDGET USED. If you have extracted useful data, try completing now. Do not navigate away from pages with relevant content without attempting completion first`;
499
591
  }
500
592
  }
501
- // Append snapshot diff when available and compact (< 30% of full snapshot)
502
- if (state.snapshotDiff && state.snapshotDiff.length < state.snapshot.length * 0.3) {
593
+ // Append snapshot diff only when NOT using diff-only mode (avoid redundant info)
594
+ if (!useDiffOnly && state.snapshotDiff && state.snapshotDiff.length < state.snapshot.length * 0.3) {
503
595
  textContent += `\n\nSNAPSHOT CHANGES (since last turn):\n${state.snapshotDiff}`;
504
596
  }
505
597
  if (extraContext) {
@@ -523,7 +615,10 @@ ${visibleSnapshot}`;
523
615
  ...this.compactHistory(),
524
616
  { role: 'user', content: userContent },
525
617
  ];
526
- const result = await this.generate(useCompactFirstTurn ? FIRST_TURN_COMPACT_PROMPT : this.systemPrompt, messages, { provider: effectiveProvider, model: effectiveModel }, useCompactFirstTurn ? 500 : 1000);
618
+ const dynamicSystemPrompt = useCompactFirstTurn
619
+ ? FIRST_TURN_COMPACT_PROMPT
620
+ : this.buildSystemPrompt(goal, state, turnInfo?.current ?? 1);
621
+ const result = await this.generate(dynamicSystemPrompt, messages, { provider: effectiveProvider, model: effectiveModel }, useCompactFirstTurn ? 500 : 600);
527
622
  const raw = result.text;
528
623
  const tokensUsed = result.tokensUsed;
529
624
  if (!raw) {
@@ -601,18 +696,14 @@ Please evaluate the quality of this page/application.`;
601
696
  }
602
697
  async recommendLinkCandidate(goal, state, candidates, extraContext) {
603
698
  const topCandidates = candidates.slice(0, 5);
699
+ // Scout only needs candidates + context, not the full snapshot (saves 2-8k tokens)
604
700
  const lines = [
605
701
  `GOAL: ${goal}`,
606
702
  '',
607
- 'CURRENT PAGE:',
608
- `URL: ${state.url}`,
609
- `Title: ${state.title}`,
610
- '',
611
- 'ELEMENTS:',
612
- state.snapshot,
703
+ `PAGE: ${state.url} — ${state.title}`,
613
704
  '',
614
705
  'CANDIDATES:',
615
- ...topCandidates.map((candidate, index) => `${index + 1}. ${candidate.ref} — ${candidate.text} (deterministic score ${candidate.score})`),
706
+ ...topCandidates.map((candidate, index) => `${index + 1}. ${candidate.ref} — ${candidate.text} (score ${candidate.score})`),
616
707
  ];
617
708
  if (extraContext) {
618
709
  lines.push('', extraContext);
@@ -686,30 +777,11 @@ Was the goal actually achieved? Analyze the current page state carefully.`;
686
777
  const verifyModel = this.adaptiveModelRouting && this.navModelName
687
778
  ? this.navModelName
688
779
  : undefined;
689
- const result = await this.generate(`You are verifying whether a browser automation agent actually achieved its goal.
690
-
691
- Analyze the page state (screenshot + accessibility tree) and determine if the stated goal was accomplished.
692
-
693
- Check the page state and claimed result carefully:
694
- 1. Does the current page state show the goal was completed?
695
- 2. Are there error messages, incomplete forms, or missing elements?
696
- 3. Does the URL match what you'd expect after goal completion?
697
- 4. Is the claimed result consistent with what's visible on the page?
698
- 5. CRITICAL — SUPPLEMENTAL TOOL EVIDENCE: If the claimed result includes "SUPPLEMENTAL TOOL EVIDENCE" or "SCRIPT RESULT" sections, this data was extracted programmatically from the actual page DOM via JavaScript. This evidence is VERIFIED and TRUSTWORTHY — treat it as equivalent to data visible on the current page. It can fully satisfy data requirements (titles, dates, prices, ratings, counts, URLs) even if the current page no longer shows that data. Do NOT reject a completion simply because the extracted data isn't visible in the current accessibility tree.
699
- 6. MULTI-PAGE TASKS: For goals requiring data from multiple pages (e.g., "find X and extract Y"), the agent may have navigated through several pages collecting data via runScript. If the claimed result contains specific data points that match the SUPPLEMENTAL TOOL EVIDENCE, accept the completion even if the current page is a different page from where the data was extracted.
700
-
701
- Respond with ONLY a JSON object:
702
- {
703
- "achieved": true,
704
- "confidence": 0.9,
705
- "evidence": ["The dashboard shows the new item", "URL changed to /success"],
706
- "missing": []
707
- }
780
+ const result = await this.generate(`Verify whether the browser agent achieved its goal. Respond with ONLY JSON:
781
+ {"achieved":true,"confidence":0.9,"evidence":["observation"],"missing":[]}
708
782
 
709
- - achieved: true if the goal is clearly met, false if not or uncertain
710
- - confidence: 0.0 to 1.0 how sure are you?
711
- - evidence: specific observations supporting your judgment
712
- - missing: what's still needed (empty array if achieved)`, [{ role: 'user', content: userContent }], verifyProvider && verifyModel ? { provider: verifyProvider, model: verifyModel } : undefined, 600);
783
+ Check: page state matches goal, no errors, URL is expected, claimed result matches visible data.
784
+ SUPPLEMENTAL TOOL EVIDENCE / SCRIPT RESULT in claimed results = verified DOM data, trustworthy even if page navigated away. Multi-page data collection is valid.`, [{ role: 'user', content: userContent }], verifyProvider && verifyModel ? { provider: verifyProvider, model: verifyModel } : undefined, 600);
713
785
  const raw = result.text;
714
786
  if (this.debug) {
715
787
  console.log('[Brain] Goal verification:', raw.slice(0, 300));
@@ -744,7 +816,7 @@ Respond with ONLY a JSON object:
744
816
  * Uses vision to analyze layout, typography, spacing, contrast, and UX.
745
817
  * Returns structured findings with categories and severity levels.
746
818
  */
747
- async auditDesign(state, goal, checkpoints) {
819
+ async auditDesign(state, goal, checkpoints, systemPrompt) {
748
820
  const textContent = `GOAL: ${goal}
749
821
 
750
822
  CHECKPOINTS to verify:
@@ -759,7 +831,7 @@ ${state.snapshot}
759
831
 
760
832
  Audit this page for design quality, UX issues, and visual bugs.`;
761
833
  const userContent = this.buildUserContent(textContent, state.screenshot, true);
762
- const result = await this.generate(DESIGN_AUDIT_PROMPT, [{ role: 'user', content: userContent }], undefined, 1500);
834
+ const result = await this.generate(systemPrompt ?? DESIGN_AUDIT_PROMPT, [{ role: 'user', content: userContent }], undefined, 4000);
763
835
  const raw = result.text;
764
836
  const tokensUsed = result.tokensUsed;
765
837
  if (this.debug) {
@@ -770,7 +842,21 @@ Audit this page for design quality, UX issues, and visual bugs.`;
770
842
  if (text.startsWith('```')) {
771
843
  text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
772
844
  }
773
- const parsed = JSON.parse(text);
845
+ // Extract JSON object if surrounded by non-JSON text or truncated
846
+ let parsed;
847
+ try {
848
+ parsed = JSON.parse(text);
849
+ }
850
+ catch {
851
+ const start = text.indexOf('{');
852
+ const end = text.lastIndexOf('}');
853
+ if (start >= 0 && end > start) {
854
+ parsed = JSON.parse(text.slice(start, end + 1));
855
+ }
856
+ else {
857
+ throw new Error('No JSON object found');
858
+ }
859
+ }
774
860
  const VALID_CATEGORIES = new Set(['visual-bug', 'layout', 'contrast', 'alignment', 'spacing', 'typography', 'accessibility', 'ux']);
775
861
  const VALID_SEVERITIES = new Set(['critical', 'major', 'minor']);
776
862
  const findings = Array.isArray(parsed.findings)
@@ -892,12 +978,74 @@ Only include facts that are genuinely useful. Quality over quantity. Max 10 fact
892
978
  }
893
979
  }
894
980
  }
981
+ /**
982
+ * Collapse consecutive runs of similar elements (same indent + role, names
983
+ * differing only by a trailing number/short suffix) into a single representative
984
+ * line with a count. Reduces token cost on pages with long pagination, nav
985
+ * lists, or repeated product cards.
986
+ *
987
+ * Skips dialog/alertdialog (agent must see each one) and groups < 3 items.
988
+ */
989
+ function deduplicateSnapshot(snapshot) {
990
+ const lines = snapshot.split('\n');
991
+ const out = [];
992
+ // Extract (indent, role) from a snapshot line. Returns null for non-element lines.
993
+ const parseLine = (line) => {
994
+ const m = line.match(/^(\s*-\s+)(\w+)\s+"([^"]*)"\s*\[ref=(\w+)\]/);
995
+ if (!m)
996
+ return null;
997
+ return { indent: m[1], role: m[2], name: m[3], ref: m[4], full: line };
998
+ };
999
+ // Strip trailing numbers/ordinals to get a "name stem" for grouping.
1000
+ // "Page 1" and "Page 20" → "Page ", "Item #3" and "Item #42" → "Item #"
1001
+ const nameStem = (name) => name.replace(/\d+/g, '#');
1002
+ let i = 0;
1003
+ while (i < lines.length) {
1004
+ const parsed = parseLine(lines[i]);
1005
+ // Non-element line or dialog/alertdialog — emit as-is
1006
+ if (!parsed || /\b(?:dialog|alertdialog)\b/i.test(parsed.role)) {
1007
+ out.push(lines[i]);
1008
+ i++;
1009
+ continue;
1010
+ }
1011
+ // Collect a consecutive run of same (indent, role) with similar name stems
1012
+ const group = [parsed];
1013
+ const stem = nameStem(parsed.name);
1014
+ let j = i + 1;
1015
+ while (j < lines.length) {
1016
+ const next = parseLine(lines[j]);
1017
+ if (!next ||
1018
+ next.indent !== parsed.indent ||
1019
+ next.role !== parsed.role ||
1020
+ nameStem(next.name) !== stem)
1021
+ break;
1022
+ group.push(next);
1023
+ j++;
1024
+ }
1025
+ if (group.length < 3) {
1026
+ // Not enough to dedup — emit originals
1027
+ for (const g of group)
1028
+ out.push(g.full);
1029
+ }
1030
+ else {
1031
+ // Emit first element with a summary of the rest
1032
+ const last = group[group.length - 1];
1033
+ out.push(`${parsed.full} (+${group.length - 1} similar: "${group[1].name}"\u2026"${last.name}")`);
1034
+ }
1035
+ i = j;
1036
+ }
1037
+ return out.join('\n');
1038
+ }
895
1039
  /**
896
1040
  * Cap snapshot size for non-first turns to control token cost on large pages.
897
1041
  * Keeps the full snapshot when it fits within budget; otherwise truncates
898
1042
  * non-interactive decorative lines first, then hard-caps with a notice.
899
1043
  */
900
1044
  function budgetSnapshot(snapshot, maxChars = 16_000) {
1045
+ // Skip dedup on small snapshots — not enough repetition to justify the O(n) scan
1046
+ if (snapshot.length > 6_000) {
1047
+ snapshot = deduplicateSnapshot(snapshot);
1048
+ }
901
1049
  if (snapshot.length <= maxChars)
902
1050
  return snapshot;
903
1051
  // First pass: drop non-interactive lines (images, paragraphs, decorative text)